feat: Add FAB with asset price update, mobile optimizations, and localized financial trend

- Add Floating Action Button (FAB) with 3 quick actions
- Implement Asset Price Update dialog for bulk price updates
- Add bulk price update API endpoint with transaction support
- Optimize multiselect, calendar, and dropdown options for mobile (44px touch targets)
- Add custom date range popover to save space in Overview header
- Localize number format suffixes (k/m/b for EN, rb/jt/m for ID)
- Localize date format in Financial Trend (Oct 8 vs 8 Okt)
- Fix negative values in trend line chart (domain auto)
- Improve Asset Price Update dialog layout (compact horizontal)
- Add mobile-optimized calendar with responsive cells
- Fix FAB overlay and close button position
- Add translations for FAB and asset price updates
This commit is contained in:
dwindown
2025-10-12 23:30:54 +07:00
parent 46488a09e2
commit 49d60676d0
33 changed files with 1340 additions and 444 deletions

203
PROJECT_STANDARDS.md Normal file
View File

@@ -0,0 +1,203 @@
# Tabungin Project Standards
## 🌐 Multilingual Support (COMPLETED)
### Languages Supported:
-**English (EN)** - Full translation
-**Indonesian (ID)** - Full translation (Default)
### Implementation:
- **Location:** `/apps/web/src/locales/`
- `en.ts` - English translations
- `id.ts` - Indonesian translations
- **Context:** `LanguageContext.tsx` - Language state management
- **Component:** `LanguageToggle.tsx` - Language switcher UI
- **Usage:** `const { t } = useLanguage()` then `t.section.key`
### Translation Structure:
```typescript
{
nav: { ... },
overview: { ... },
wallets: { ... },
transactions: { ... },
profile: { ... },
walletDialog: { ... },
transactionDialog: { ... },
dateRange: { ... }
}
```
### Rules:
1.**ALL user-facing text MUST use translations** - No hardcoded strings
2.**Both EN and ID must have identical keys** - Type-safe with TypeScript
3.**Toast messages use translations** - `toast.success(t.section.key)`
4.**Error messages use translations** - Consistent UX
---
## 🎨 UI Component Library (COMPLETED)
### Framework: **shadcn/ui**
- Built on Radix UI primitives
- Tailwind CSS styling
- Fully customizable components
### Components Used:
- ✅ Button, Input, Label, Badge
- ✅ Card, Alert, Separator, Tabs
- ✅ Dialog, Drawer (Responsive)
- ✅ Select, Popover, Calendar
- ✅ Sidebar, Breadcrumb
- ✅ Toast (Sonner)
### Styling Convention:
- **Tailwind CSS** for all styling
- **Dark mode support** via `useTheme` hook
- **Responsive design** with `md:`, `lg:` breakpoints
---
## 📱 Mobile UI/UX Optimization (COMPLETED ✅)
### Status:
-**Profile Page** - Fully optimized
-**Overview Page** - Fully optimized
-**Wallets Page** - Fully optimized
-**Transactions Page** - Fully optimized
-**Responsive Dialogs** - Desktop (Dialog) / Mobile (Drawer)
### Mobile Standards:
#### 1. Touch Target Sizes
- **Buttons:** `h-11` (44px) on mobile, `h-10` on desktop
- **Inputs:** `h-11` (44px) on mobile, `h-10` on desktop
- **Minimum width:** `min-w-[100px]` for buttons
- **Padding:** `px-6` on mobile, `px-4` on desktop
#### 2. Typography
- **Inputs/Labels:** `text-base` (16px) on mobile to prevent iOS zoom
- **Desktop:** `text-sm` (14px) for compact layout
- **Responsive:** `text-base md:text-sm`
#### 3. Spacing
- **Form fields:** `space-y-3` on mobile, `space-y-2` on desktop
- **Touch targets:** Minimum 8px gap between tappable elements
- **Padding:** More generous on mobile (`p-6` vs `p-4`)
#### 4. Responsive Patterns
```tsx
// Button sizing
<Button className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm">
// Input sizing
<Input className="h-11 md:h-9 text-base md:text-sm" />
// Label sizing
<Label className="text-base md:text-sm">
// Spacing
<div className="space-y-3 md:space-y-2">
```
#### 5. Responsive Dialog/Drawer
- **Desktop (≥768px):** Uses `Dialog` (modal)
- **Mobile (<768px):** Uses `Drawer` (bottom sheet)
- **Component:** `ResponsiveDialog` wrapper
- **Usage:** Replace `Dialog` with `ResponsiveDialog`
---
## 🏗️ Architecture Standards
### Frontend Structure:
```
/apps/web/src/
├── components/
│ ├── dialogs/ # Form dialogs (Wallet, Transaction)
│ ├── layout/ # Layout components (Sidebar, Dashboard)
│ ├── pages/ # Page components (Overview, Profile, etc.)
│ └── ui/ # shadcn components
├── contexts/ # React contexts (Auth, Language, Theme)
├── hooks/ # Custom hooks
├── locales/ # Translation files (en.ts, id.ts)
└── lib/ # Utilities
```
### Backend Structure:
```
/apps/api/src/
├── auth/ # Authentication module
├── users/ # User management
├── wallets/ # Wallet CRUD
├── transactions/ # Transaction CRUD
├── otp/ # OTP verification (Email, WhatsApp, TOTP)
└── prisma/ # Database client
```
---
## 📋 Development Checklist for New Pages
When creating a new page, ensure:
### ✅ Multilingual
- [ ] All text uses `t.section.key` from translations
- [ ] Both `en.ts` and `id.ts` have matching keys
- [ ] Toast messages are translated
- [ ] Error messages are translated
### ✅ UI Components
- [ ] Uses shadcn/ui components
- [ ] Follows existing design patterns
- [ ] Dark mode compatible
### ✅ Mobile Optimization
- [ ] Buttons: `h-11 md:h-9 px-6 md:px-4 text-base md:text-sm`
- [ ] Inputs: `h-11 md:h-9 text-base md:text-sm`
- [ ] Labels: `text-base md:text-sm`
- [ ] Spacing: `space-y-3 md:space-y-2`
- [ ] Dialogs use `ResponsiveDialog` component
- [ ] Touch targets meet 44px minimum
- [ ] Tested on mobile viewport
### ✅ Code Quality
- [ ] TypeScript strict mode passing
- [ ] No lint errors/warnings
- [ ] Follows existing patterns
- [ ] Proper error handling
---
## 🎯 Next Steps
### Completed:
1. Profile page mobile optimization
2. Overview page mobile optimization
3. Wallets page mobile optimization
4. Transactions page mobile optimization
5. WhatsApp number validation
6. Language toggle moved to header
7. Responsive Dialog/Drawer system
### Future Enhancements:
- [ ] Add more languages (if needed)
- [ ] Improve loading states
- [ ] Add skeleton loaders
- [ ] Optimize performance
- [ ] Add E2E tests
---
## 📝 Notes
- **Default Language:** Indonesian (ID)
- **Theme:** Light/Dark mode support
- **Responsive Breakpoint:** 768px (md)
- **Touch Target Standard:** 44px (Apple HIG & Material Design)
- **Font Size (Mobile):** 16px minimum to prevent iOS zoom
---
**Last Updated:** October 12, 2025
**Status:** Mobile optimization in progress

View File

@@ -321,11 +321,11 @@ let OtpService = class OtpService {
} }
catch (error) { catch (error) {
console.error('Failed to check WhatsApp number:', error); console.error('Failed to check WhatsApp number:', error);
console.log(`📱 Checking WhatsApp number: ${phone} - Assumed valid`); console.log(`📱 Failed to check WhatsApp number: ${phone} - Webhook error`);
return { return {
success: true, success: false,
isRegistered: true, isRegistered: false,
message: 'Number is valid (dev mode)', message: 'Unable to verify WhatsApp number. Please try again.',
}; };
} }
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -11,11 +11,11 @@ export declare class WalletsController {
constructor(wallets: WalletsService, transactions: TransactionsService); constructor(wallets: WalletsService, transactions: TransactionsService);
list(req: RequestWithUser): import("@prisma/client").Prisma.PrismaPromise<{ list(req: RequestWithUser): import("@prisma/client").Prisma.PrismaPromise<{
id: string; id: string;
userId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
name: string;
userId: string;
kind: string; kind: string;
name: string;
currency: string | null; currency: string | null;
unit: string | null; unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null; initialAmount: import("@prisma/client/runtime/library").Decimal | null;
@@ -23,15 +23,15 @@ export declare class WalletsController {
deletedAt: Date | null; deletedAt: Date | null;
}[]>; }[]>;
getAllTransactions(req: RequestWithUser): Promise<{ getAllTransactions(req: RequestWithUser): Promise<{
category: string | null;
id: string; id: string;
createdAt: Date;
userId: string; userId: string;
createdAt: Date;
walletId: string;
date: Date;
amount: import("@prisma/client/runtime/library").Decimal; amount: import("@prisma/client/runtime/library").Decimal;
direction: string; direction: string;
date: Date; category: string | null;
memo: string | null; memo: string | null;
walletId: string;
recurrenceId: string | null; recurrenceId: string | null;
}[]>; }[]>;
create(req: RequestWithUser, body: { create(req: RequestWithUser, body: {
@@ -43,11 +43,11 @@ export declare class WalletsController {
pricePerUnit?: number; pricePerUnit?: number;
}): import("@prisma/client").Prisma.Prisma__WalletClient<{ }): import("@prisma/client").Prisma.Prisma__WalletClient<{
id: string; id: string;
userId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
name: string;
userId: string;
kind: string; kind: string;
name: string;
currency: string | null; currency: string | null;
unit: string | null; unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null; initialAmount: import("@prisma/client/runtime/library").Decimal | null;
@@ -65,24 +65,48 @@ export declare class WalletsController {
pricePerUnit?: number; pricePerUnit?: number;
}): import("@prisma/client").Prisma.Prisma__WalletClient<{ }): import("@prisma/client").Prisma.Prisma__WalletClient<{
id: string; id: string;
userId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
name: string;
userId: string;
kind: string; kind: string;
name: string;
currency: string | null; currency: string | null;
unit: string | null; unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null; initialAmount: import("@prisma/client/runtime/library").Decimal | null;
pricePerUnit: import("@prisma/client/runtime/library").Decimal | null; pricePerUnit: import("@prisma/client/runtime/library").Decimal | null;
deletedAt: Date | null; deletedAt: Date | null;
}, never, import("@prisma/client/runtime/library").DefaultArgs, import("@prisma/client").Prisma.PrismaClientOptions>; }, never, import("@prisma/client/runtime/library").DefaultArgs, import("@prisma/client").Prisma.PrismaClientOptions>;
delete(req: RequestWithUser, id: string): import("@prisma/client").Prisma.Prisma__WalletClient<{ bulkUpdatePrices(req: RequestWithUser, body: {
updates: Array<{
walletId: string;
pricePerUnit: number;
}>;
}): Promise<{
success: boolean;
updated: number;
wallets: {
id: string; id: string;
userId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
name: string;
userId: string;
kind: string; kind: string;
name: string;
currency: string | null;
unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null;
pricePerUnit: import("@prisma/client/runtime/library").Decimal | null;
deletedAt: Date | null;
}[];
}> | {
error: string;
};
delete(req: RequestWithUser, id: string): import("@prisma/client").Prisma.Prisma__WalletClient<{
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
kind: string;
name: string;
currency: string | null; currency: string | null;
unit: string | null; unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null; initialAmount: import("@prisma/client/runtime/library").Decimal | null;

View File

@@ -39,6 +39,12 @@ let WalletsController = class WalletsController {
update(req, id, body) { update(req, id, body) {
return this.wallets.update(req.user.userId, id, body); return this.wallets.update(req.user.userId, id, body);
} }
bulkUpdatePrices(req, body) {
if (!body?.updates || !Array.isArray(body.updates)) {
return { error: 'updates array is required' };
}
return this.wallets.bulkUpdatePrices(req.user.userId, body.updates);
}
delete(req, id) { delete(req, id) {
return this.wallets.delete(req.user.userId, id); return this.wallets.delete(req.user.userId, id);
} }
@@ -75,6 +81,14 @@ __decorate([
__metadata("design:paramtypes", [Object, String, Object]), __metadata("design:paramtypes", [Object, String, Object]),
__metadata("design:returntype", void 0) __metadata("design:returntype", void 0)
], WalletsController.prototype, "update", null); ], WalletsController.prototype, "update", null);
__decorate([
(0, common_1.Patch)('bulk-update-prices'),
__param(0, (0, common_1.Req)()),
__param(1, (0, common_1.Body)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object, Object]),
__metadata("design:returntype", void 0)
], WalletsController.prototype, "bulkUpdatePrices", null);
__decorate([ __decorate([
(0, common_1.Delete)(':id'), (0, common_1.Delete)(':id'),
__param(0, (0, common_1.Req)()), __param(0, (0, common_1.Req)()),

View File

@@ -1 +1 @@
{"version":3,"file":"wallets.controller.js","sourceRoot":"","sources":["../../src/wallets/wallets.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAUwB;AACxB,uDAAmD;AACnD,+EAA2E;AAC3E,mDAA+C;AAUxC,IAAM,iBAAiB,GAAvB,MAAM,iBAAiB;IAET;IACA;IAFnB,YACmB,OAAuB,EACvB,YAAiC;QADjC,YAAO,GAAP,OAAO,CAAgB;QACvB,iBAAY,GAAZ,YAAY,CAAqB;IACjD,CAAC;IAGJ,IAAI,CAAQ,GAAoB;QAC9B,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC5C,CAAC;IAGK,AAAN,KAAK,CAAC,kBAAkB,CAAQ,GAAoB;QAClD,OAAO,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACpD,CAAC;IAGD,MAAM,CACG,GAAoB,EAE3B,IAOC;QAED,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC;YAChB,OAAO,EAAE,KAAK,EAAE,kBAAkB,EAAE,CAAC;QACvC,CAAC;QACD,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IACpD,CAAC;IAGD,MAAM,CACG,GAAoB,EACd,EAAU,EAEvB,IAOC;QAED,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,EAAE,IAAI,CAAC,CAAC;IACxD,CAAC;IAGD,MAAM,CAAQ,GAAoB,EAAe,EAAU;QACzD,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAClD,CAAC;CACF,CAAA;AAxDY,8CAAiB;AAO5B;IADC,IAAA,YAAG,GAAE;IACA,WAAA,IAAA,YAAG,GAAE,CAAA;;;;6CAEV;AAGK;IADL,IAAA,YAAG,EAAC,cAAc,CAAC;IACM,WAAA,IAAA,YAAG,GAAE,CAAA;;;;2DAE9B;AAGD;IADC,IAAA,aAAI,GAAE;IAEJ,WAAA,IAAA,YAAG,GAAE,CAAA;IACL,WAAA,IAAA,aAAI,GAAE,CAAA;;;;+CAcR;AAGD;IADC,IAAA,YAAG,EAAC,KAAK,CAAC;IAER,WAAA,IAAA,YAAG,GAAE,CAAA;IACL,WAAA,IAAA,cAAK,EAAC,IAAI,CAAC,CAAA;IACX,WAAA,IAAA,aAAI,GAAE,CAAA;;;;+CAWR;AAGD;IADC,IAAA,eAAM,EAAC,KAAK,CAAC;IACN,WAAA,IAAA,YAAG,GAAE,CAAA;IAAwB,WAAA,IAAA,cAAK,EAAC,IAAI,CAAC,CAAA;;;;+CAE/C;4BAvDU,iBAAiB;IAF7B,IAAA,mBAAU,EAAC,SAAS,CAAC;IACrB,IAAA,kBAAS,EAAC,sBAAS,CAAC;qCAGS,gCAAc;QACT,0CAAmB;GAHzC,iBAAiB,CAwD7B"} {"version":3,"file":"wallets.controller.js","sourceRoot":"","sources":["../../src/wallets/wallets.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAWwB;AACxB,uDAAmD;AACnD,+EAA2E;AAC3E,mDAA+C;AAUxC,IAAM,iBAAiB,GAAvB,MAAM,iBAAiB;IAET;IACA;IAFnB,YACmB,OAAuB,EACvB,YAAiC;QADjC,YAAO,GAAP,OAAO,CAAgB;QACvB,iBAAY,GAAZ,YAAY,CAAqB;IACjD,CAAC;IAGJ,IAAI,CAAQ,GAAoB;QAC9B,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC5C,CAAC;IAGK,AAAN,KAAK,CAAC,kBAAkB,CAAQ,GAAoB;QAClD,OAAO,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACpD,CAAC;IAGD,MAAM,CACG,GAAoB,EAE3B,IAOC;QAED,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC;YAChB,OAAO,EAAE,KAAK,EAAE,kBAAkB,EAAE,CAAC;QACvC,CAAC;QACD,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IACpD,CAAC;IAGD,MAAM,CACG,GAAoB,EACd,EAAU,EAEvB,IAOC;QAED,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,EAAE,IAAI,CAAC,CAAC;IACxD,CAAC;IAGD,gBAAgB,CACP,GAAoB,EAE3B,IAKC;QAED,IAAI,CAAC,IAAI,EAAE,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;YACnD,OAAO,EAAE,KAAK,EAAE,2BAA2B,EAAE,CAAC;QAChD,CAAC;QACD,OAAO,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IACtE,CAAC;IAGD,MAAM,CAAQ,GAAoB,EAAe,EAAU;QACzD,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAClD,CAAC;CACF,CAAA;AAzEY,8CAAiB;AAO5B;IADC,IAAA,YAAG,GAAE;IACA,WAAA,IAAA,YAAG,GAAE,CAAA;;;;6CAEV;AAGK;IADL,IAAA,YAAG,EAAC,cAAc,CAAC;IACM,WAAA,IAAA,YAAG,GAAE,CAAA;;;;2DAE9B;AAGD;IADC,IAAA,aAAI,GAAE;IAEJ,WAAA,IAAA,YAAG,GAAE,CAAA;IACL,WAAA,IAAA,aAAI,GAAE,CAAA;;;;+CAcR;AAGD;IADC,IAAA,YAAG,EAAC,KAAK,CAAC;IAER,WAAA,IAAA,YAAG,GAAE,CAAA;IACL,WAAA,IAAA,cAAK,EAAC,IAAI,CAAC,CAAA;IACX,WAAA,IAAA,aAAI,GAAE,CAAA;;;;+CAWR;AAGD;IADC,IAAA,cAAK,EAAC,oBAAoB,CAAC;IAEzB,WAAA,IAAA,YAAG,GAAE,CAAA;IACL,WAAA,IAAA,aAAI,GAAE,CAAA;;;;yDAYR;AAGD;IADC,IAAA,eAAM,EAAC,KAAK,CAAC;IACN,WAAA,IAAA,YAAG,GAAE,CAAA;IAAwB,WAAA,IAAA,cAAK,EAAC,IAAI,CAAC,CAAA;;;;+CAE/C;4BAxEU,iBAAiB;IAF7B,IAAA,mBAAU,EAAC,SAAS,CAAC;IACrB,IAAA,kBAAS,EAAC,sBAAS,CAAC;qCAGS,gCAAc;QACT,0CAAmB;GAHzC,iBAAiB,CAyE7B"}

View File

@@ -4,11 +4,11 @@ export declare class WalletsService {
constructor(prisma: PrismaService); constructor(prisma: PrismaService);
list(userId: string): import("@prisma/client").Prisma.PrismaPromise<{ list(userId: string): import("@prisma/client").Prisma.PrismaPromise<{
id: string; id: string;
userId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
name: string;
userId: string;
kind: string; kind: string;
name: string;
currency: string | null; currency: string | null;
unit: string | null; unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null; initialAmount: import("@prisma/client/runtime/library").Decimal | null;
@@ -24,11 +24,11 @@ export declare class WalletsService {
pricePerUnit?: number; pricePerUnit?: number;
}): import("@prisma/client").Prisma.Prisma__WalletClient<{ }): import("@prisma/client").Prisma.Prisma__WalletClient<{
id: string; id: string;
userId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
name: string;
userId: string;
kind: string; kind: string;
name: string;
currency: string | null; currency: string | null;
unit: string | null; unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null; initialAmount: import("@prisma/client/runtime/library").Decimal | null;
@@ -44,24 +44,44 @@ export declare class WalletsService {
pricePerUnit?: number; pricePerUnit?: number;
}): import("@prisma/client").Prisma.Prisma__WalletClient<{ }): import("@prisma/client").Prisma.Prisma__WalletClient<{
id: string; id: string;
userId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
name: string;
userId: string;
kind: string; kind: string;
name: string;
currency: string | null; currency: string | null;
unit: string | null; unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null; initialAmount: import("@prisma/client/runtime/library").Decimal | null;
pricePerUnit: import("@prisma/client/runtime/library").Decimal | null; pricePerUnit: import("@prisma/client/runtime/library").Decimal | null;
deletedAt: Date | null; deletedAt: Date | null;
}, never, import("@prisma/client/runtime/library").DefaultArgs, import("@prisma/client").Prisma.PrismaClientOptions>; }, never, import("@prisma/client/runtime/library").DefaultArgs, import("@prisma/client").Prisma.PrismaClientOptions>;
delete(userId: string, id: string): import("@prisma/client").Prisma.Prisma__WalletClient<{ bulkUpdatePrices(userId: string, updates: Array<{
walletId: string;
pricePerUnit: number;
}>): Promise<{
success: boolean;
updated: number;
wallets: {
id: string; id: string;
userId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
name: string;
userId: string;
kind: string; kind: string;
name: string;
currency: string | null;
unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null;
pricePerUnit: import("@prisma/client/runtime/library").Decimal | null;
deletedAt: Date | null;
}[];
}>;
delete(userId: string, id: string): import("@prisma/client").Prisma.Prisma__WalletClient<{
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
kind: string;
name: string;
currency: string | null; currency: string | null;
unit: string | null; unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null; initialAmount: import("@prisma/client/runtime/library").Decimal | null;

View File

@@ -67,6 +67,17 @@ let WalletsService = class WalletsService {
data: updateData, data: updateData,
}); });
} }
async bulkUpdatePrices(userId, updates) {
const results = await this.prisma.$transaction(updates.map((update) => this.prisma.wallet.update({
where: { id: update.walletId, userId, kind: 'asset' },
data: { pricePerUnit: update.pricePerUnit },
})));
return {
success: true,
updated: results.length,
wallets: results,
};
}
delete(userId, id) { delete(userId, id) {
return this.prisma.wallet.update({ return this.prisma.wallet.update({
where: { id, userId }, where: { id, userId },

View File

@@ -1 +1 @@
{"version":3,"file":"wallets.service.js","sourceRoot":"","sources":["../../src/wallets/wallets.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA4C;AAC5C,6DAAyD;AAGlD,IAAM,cAAc,GAApB,MAAM,cAAc;IACL;IAApB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,IAAI,CAAC,MAAc;QACjB,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC;YACjC,KAAK,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE;YAClC,OAAO,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE;SAC9B,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CACJ,MAAc,EACd,KAOC;QAED,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,OAAO,CAAC;QACnC,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,IAAI,EAAE;gBACJ,MAAM;gBACN,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,IAAI;gBACJ,QAAQ,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI;gBAC7D,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI;gBACpD,aAAa,EAAE,KAAK,CAAC,aAAa,IAAI,IAAI;gBAC1C,YAAY,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,YAAY,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI;aACnE;SACF,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CACJ,MAAc,EACd,EAAU,EACV,KAOC;QAED,MAAM,UAAU,GAAQ,EAAE,CAAC;QAE3B,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS;YAAE,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;QAC3D,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC7B,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;YAE7B,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBAC3B,UAAU,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC;gBAC9C,UAAU,CAAC,IAAI,GAAG,IAAI,CAAC;YACzB,CAAC;iBAAM,CAAC;gBACN,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC;gBACrC,UAAU,CAAC,QAAQ,GAAG,IAAI,CAAC;YAC7B,CAAC;QACH,CAAC;aAAM,CAAC;YAEN,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS;gBAAE,UAAU,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAC;YACvE,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS;gBAAE,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;QAC7D,CAAC;QAGD,IAAI,KAAK,CAAC,aAAa,KAAK,SAAS;YACnC,UAAU,CAAC,aAAa,GAAG,KAAK,CAAC,aAAa,IAAI,IAAI,CAAC;QACzD,IAAI,KAAK,CAAC,YAAY,KAAK,SAAS;YAClC,UAAU,CAAC,YAAY,GAAG,KAAK,CAAC,YAAY,IAAI,IAAI,CAAC;QAEvD,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;YACrB,IAAI,EAAE,UAAU;SACjB,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CAAC,MAAc,EAAE,EAAU;QAE/B,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;YACrB,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,EAAE;SAChC,CAAC,CAAC;IACL,CAAC;CACF,CAAA;AArFY,wCAAc;yBAAd,cAAc;IAD1B,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,cAAc,CAqF1B"} {"version":3,"file":"wallets.service.js","sourceRoot":"","sources":["../../src/wallets/wallets.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA4C;AAC5C,6DAAyD;AAGlD,IAAM,cAAc,GAApB,MAAM,cAAc;IACL;IAApB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,IAAI,CAAC,MAAc;QACjB,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC;YACjC,KAAK,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE;YAClC,OAAO,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE;SAC9B,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CACJ,MAAc,EACd,KAOC;QAED,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,OAAO,CAAC;QACnC,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,IAAI,EAAE;gBACJ,MAAM;gBACN,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,IAAI;gBACJ,QAAQ,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI;gBAC7D,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI;gBACpD,aAAa,EAAE,KAAK,CAAC,aAAa,IAAI,IAAI;gBAC1C,YAAY,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,YAAY,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI;aACnE;SACF,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CACJ,MAAc,EACd,EAAU,EACV,KAOC;QAED,MAAM,UAAU,GAAQ,EAAE,CAAC;QAE3B,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS;YAAE,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;QAC3D,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC7B,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;YAE7B,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBAC3B,UAAU,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC;gBAC9C,UAAU,CAAC,IAAI,GAAG,IAAI,CAAC;YACzB,CAAC;iBAAM,CAAC;gBACN,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC;gBACrC,UAAU,CAAC,QAAQ,GAAG,IAAI,CAAC;YAC7B,CAAC;QACH,CAAC;aAAM,CAAC;YAEN,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS;gBAAE,UAAU,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAC;YACvE,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS;gBAAE,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;QAC7D,CAAC;QAGD,IAAI,KAAK,CAAC,aAAa,KAAK,SAAS;YACnC,UAAU,CAAC,aAAa,GAAG,KAAK,CAAC,aAAa,IAAI,IAAI,CAAC;QACzD,IAAI,KAAK,CAAC,YAAY,KAAK,SAAS;YAClC,UAAU,CAAC,YAAY,GAAG,KAAK,CAAC,YAAY,IAAI,IAAI,CAAC;QAEvD,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;YACrB,IAAI,EAAE,UAAU;SACjB,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,gBAAgB,CACpB,MAAc,EACd,OAA0D;QAG1D,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,YAAY,CAC5C,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CACrB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YACxB,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE;YACrD,IAAI,EAAE,EAAE,YAAY,EAAE,MAAM,CAAC,YAAY,EAAE;SAC5C,CAAC,CACH,CACF,CAAC;QAEF,OAAO;YACL,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,OAAO,CAAC,MAAM;YACvB,OAAO,EAAE,OAAO;SACjB,CAAC;IACJ,CAAC;IAED,MAAM,CAAC,MAAc,EAAE,EAAU;QAE/B,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;YACrB,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,EAAE;SAChC,CAAC,CAAC;IACL,CAAC;CACF,CAAA;AA1GY,wCAAc;yBAAd,cAAc;IAD1B,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,cAAc,CA0G1B"}

View File

@@ -403,12 +403,12 @@ export class OtpService {
}; };
} catch (error: unknown) { } catch (error: unknown) {
console.error('Failed to check WhatsApp number:', error); console.error('Failed to check WhatsApp number:', error);
// For development, assume number is valid // Return false if webhook fails - safer approach
console.log(`📱 Checking WhatsApp number: ${phone} - Assumed valid`); console.log(`📱 Failed to check WhatsApp number: ${phone} - Webhook error`);
return { return {
success: true, success: false,
isRegistered: true, isRegistered: false,
message: 'Number is valid (dev mode)', message: 'Unable to verify WhatsApp number. Please try again.',
}; };
} }
} }

View File

@@ -4,6 +4,7 @@ import {
Get, Get,
Post, Post,
Put, Put,
Patch,
Delete, Delete,
Param, Param,
UseGuards, UseGuards,
@@ -73,6 +74,23 @@ export class WalletsController {
return this.wallets.update(req.user.userId, id, body); return this.wallets.update(req.user.userId, id, body);
} }
@Patch('bulk-update-prices')
bulkUpdatePrices(
@Req() req: RequestWithUser,
@Body()
body: {
updates: Array<{
walletId: string;
pricePerUnit: number;
}>;
},
) {
if (!body?.updates || !Array.isArray(body.updates)) {
return { error: 'updates array is required' };
}
return this.wallets.bulkUpdatePrices(req.user.userId, body.updates);
}
@Delete(':id') @Delete(':id')
delete(@Req() req: RequestWithUser, @Param('id') id: string) { delete(@Req() req: RequestWithUser, @Param('id') id: string) {
return this.wallets.delete(req.user.userId, id); return this.wallets.delete(req.user.userId, id);

View File

@@ -80,6 +80,27 @@ export class WalletsService {
}); });
} }
async bulkUpdatePrices(
userId: string,
updates: Array<{ walletId: string; pricePerUnit: number }>,
) {
// Update all wallets in a transaction
const results = await this.prisma.$transaction(
updates.map((update) =>
this.prisma.wallet.update({
where: { id: update.walletId, userId, kind: 'asset' },
data: { pricePerUnit: update.pricePerUnit },
}),
),
);
return {
success: true,
updated: results.length,
wallets: results,
};
}
delete(userId: string, id: string) { delete(userId: string, id: string) {
// Soft delete by setting deletedAt // Soft delete by setting deletedAt
return this.prisma.wallet.update({ return this.prisma.wallet.update({

View File

@@ -1,3 +1,4 @@
import { useState, useCallback } from "react"
import { Routes, Route, useLocation, useNavigate } from "react-router-dom" import { Routes, Route, useLocation, useNavigate } from "react-router-dom"
import { DashboardLayout } from "./layout/DashboardLayout" import { DashboardLayout } from "./layout/DashboardLayout"
import { Overview } from "./pages/Overview" import { Overview } from "./pages/Overview"
@@ -8,9 +9,28 @@ import { Profile } from "./pages/Profile"
export function Dashboard() { export function Dashboard() {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const [fabWalletDialogOpen, setFabWalletDialogOpen] = useState(false)
const [fabTransactionDialogOpen, setFabTransactionDialogOpen] = useState(false)
const handleOpenWalletDialog = useCallback(() => {
setFabWalletDialogOpen(true)
}, [])
const handleOpenTransactionDialog = useCallback(() => {
setFabTransactionDialogOpen(true)
}, [])
return ( return (
<DashboardLayout currentPage={location.pathname} onNavigate={navigate}> <DashboardLayout
currentPage={location.pathname}
onNavigate={navigate}
onOpenWalletDialog={handleOpenWalletDialog}
onOpenTransactionDialog={handleOpenTransactionDialog}
fabWalletDialogOpen={fabWalletDialogOpen}
setFabWalletDialogOpen={setFabWalletDialogOpen}
fabTransactionDialogOpen={fabTransactionDialogOpen}
setFabTransactionDialogOpen={setFabTransactionDialogOpen}
>
<Routes> <Routes>
<Route path="/" element={<Overview />} /> <Route path="/" element={<Overview />} />
<Route path="/wallets" element={<Wallets />} /> <Route path="/wallets" element={<Wallets />} />

View File

@@ -0,0 +1,239 @@
"use client"
import { useState, useEffect } from "react"
import { useLanguage } from "@/contexts/LanguageContext"
import axios from "axios"
import { toast } from "sonner"
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { RefreshCw, TrendingUp, AlertTriangle } from "lucide-react"
import { formatLargeNumber } from "@/utils/numberFormat"
const API = "/api"
interface Wallet {
id: string
name: string
kind: "money" | "asset"
unit?: string
pricePerUnit?: number
updatedAt: string
}
interface AssetPriceUpdate {
walletId: string
pricePerUnit: number
}
interface AssetPriceUpdateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function AssetPriceUpdateDialog({ open, onOpenChange }: AssetPriceUpdateDialogProps) {
const { t } = useLanguage()
const [loading, setLoading] = useState(false)
const [fetchLoading, setFetchLoading] = useState(true)
const [error, setError] = useState("")
const [assetWallets, setAssetWallets] = useState<Wallet[]>([])
const [priceUpdates, setPriceUpdates] = useState<Record<string, string>>({})
// Fetch asset wallets
useEffect(() => {
if (open) {
fetchAssetWallets()
}
}, [open])
const fetchAssetWallets = async () => {
try {
setFetchLoading(true)
const response = await axios.get(`${API}/wallets`)
const assets = response.data.filter((wallet: Wallet) => wallet.kind === "asset")
setAssetWallets(assets)
// Initialize price updates with current prices
const initialPrices: Record<string, string> = {}
assets.forEach((wallet: Wallet) => {
initialPrices[wallet.id] = wallet.pricePerUnit?.toString() || "0"
})
setPriceUpdates(initialPrices)
} catch (error) {
console.error("Failed to fetch asset wallets:", error)
toast.error(t.common.error)
} finally {
setFetchLoading(false)
}
}
const handlePriceChange = (walletId: string, value: string) => {
// Allow only numbers and decimal point
if (value === "" || /^\d*\.?\d*$/.test(value)) {
setPriceUpdates(prev => ({ ...prev, [walletId]: value }))
}
}
const handleUpdatePrices = async () => {
try {
setLoading(true)
setError("")
// Prepare updates array (only include changed prices)
const updates: AssetPriceUpdate[] = []
assetWallets.forEach(wallet => {
const newPrice = parseFloat(priceUpdates[wallet.id])
const currentPrice = wallet.pricePerUnit || 0
if (!isNaN(newPrice) && newPrice !== currentPrice && newPrice > 0) {
updates.push({
walletId: wallet.id,
pricePerUnit: newPrice
})
}
})
if (updates.length === 0) {
setError(t.assetPriceUpdate.noChanges)
return
}
// Update prices
await axios.patch(`${API}/wallets/bulk-update-prices`, { updates })
toast.success(t.assetPriceUpdate.updateSuccess.replace("{count}", updates.length.toString()))
onOpenChange(false)
// Trigger page reload to refresh data
window.location.reload()
} catch (error) {
const err = error as { response?: { data?: { message?: string } } }
const errorMessage = err.response?.data?.message || t.assetPriceUpdate.updateError
setError(errorMessage)
toast.error(errorMessage)
} finally {
setLoading(false)
}
}
const formatLastUpdate = (date: string) => {
const now = new Date()
const updated = new Date(date)
const diffMs = now.getTime() - updated.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return t.assetPriceUpdate.justNow
if (diffMins < 60) return t.assetPriceUpdate.minutesAgo.replace("{minutes}", diffMins.toString())
if (diffHours < 24) return t.assetPriceUpdate.hoursAgo.replace("{hours}", diffHours.toString())
return t.assetPriceUpdate.daysAgo.replace("{days}", diffDays.toString())
}
return (
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
<ResponsiveDialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
<TrendingUp className="h-5 w-5" />
{t.assetPriceUpdate.title}
</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
{t.assetPriceUpdate.description}
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<div className="flex-1 overflow-y-auto px-4 md:px-0 py-4 md:py-0">
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{fetchLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : assetWallets.length === 0 ? (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{t.assetPriceUpdate.noAssets}</AlertDescription>
</Alert>
) : (
<div className="space-y-3">
{assetWallets.map((wallet) => (
<div key={wallet.id} className="bg-secondary/30 rounded-lg p-3">
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex-1 min-w-0">
<h4 className="font-semibold truncate">{wallet.name}</h4>
<p className="text-xs text-muted-foreground">
{t.assetPriceUpdate.lastUpdated}: {formatLastUpdate(wallet.updatedAt)}
</p>
{wallet.pricePerUnit && parseFloat(priceUpdates[wallet.id]) !== wallet.pricePerUnit && (
<span className="text-muted-foreground">
<span className="font-normal text-sm">{t.assetPriceUpdate.currentPrice}</span>: <span className="font-medium">{formatLargeNumber(wallet.pricePerUnit, 'IDR')}</span>
</span>
)}
</div>
<div className="flex-shrink-0 w-32">
<Label htmlFor={`price-${wallet.id}`} className="text-muted-foreground justify-end mb-3">
{t.assetPriceUpdate.pricePerUnit.replace('{unit}', wallet.unit || '')}
</Label>
<Input
id={`price-${wallet.id}`}
type="text"
inputMode="decimal"
value={priceUpdates[wallet.id] || ""}
onChange={(e) => handlePriceChange(wallet.id, e.target.value)}
placeholder="0"
disabled={loading}
className="h-11 md:h-9 text-sm font-mono text-right"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
<ResponsiveDialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
className="h-11 md:h-9 text-base md:text-sm"
>
{t.common.cancel}
</Button>
<Button
type="button"
onClick={handleUpdatePrices}
disabled={loading || fetchLoading || assetWallets.length === 0}
className="h-11 md:h-9 text-base md:text-sm"
>
{loading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
{t.common.loading}
</>
) : (
t.assetPriceUpdate.updateAll
)}
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
)
}

View File

@@ -231,11 +231,11 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
</ResponsiveDialogDescription> </ResponsiveDialogDescription>
</ResponsiveDialogHeader> </ResponsiveDialogHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4"> <div className="grid gap-4 p-4 md:py-4 md:px-0">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="wallet">{t.transactionDialog.wallet}</Label> <Label htmlFor="wallet" className="text-base md:text-sm">{t.transactionDialog.wallet}</Label>
<Select value={walletId} onValueChange={setWalletId}> <Select value={walletId} onValueChange={setWalletId}>
<SelectTrigger> <SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.transactionDialog.selectWallet} /> <SelectValue placeholder={t.transactionDialog.selectWallet} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -250,22 +250,22 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="amount">{t.transactionDialog.amount}</Label> <Label htmlFor="amount" className="text-base md:text-sm">{t.transactionDialog.amount}</Label>
<Input <Input
id="amount" id="amount"
type="number" type="number"
step="0.01" step="0.01"
value={amount} value={amount}
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
placeholder={t.transactionDialog.amountPlaceholder} placeholder="0"
required className="h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="direction">{t.transactionDialog.direction}</Label> <Label htmlFor="direction" className="text-base md:text-sm">{t.transactionDialog.direction}</Label>
<Select value={direction} onValueChange={(value: "in" | "out") => setDirection(value)}> <Select value={direction} onValueChange={(value: "in" | "out") => setDirection(value)}>
<SelectTrigger> <SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -277,31 +277,34 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="date">{t.transactionDialog.date}</Label> <Label htmlFor="date" className="text-base md:text-sm">{t.transactionDialog.date}</Label>
<DatePicker <DatePicker
date={selectedDate} date={selectedDate}
onDateChange={(date) => date && setSelectedDate(date)} onDateChange={(date) => date && setSelectedDate(date)}
placeholder={t.transactionDialog.selectDate} placeholder={t.transactionDialog.selectDate}
className="h-11 md:h-9 text-base md:text-sm w-full"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="category">{t.transactionDialog.category}</Label> <Label htmlFor="category" className="text-base md:text-sm">{t.transactionDialog.category}</Label>
<MultipleSelector <MultipleSelector
options={categoryOptions} options={categoryOptions}
selected={categories} selected={categories}
onChange={setCategories} onChange={setCategories}
placeholder={t.transactionDialog.categoryPlaceholder} placeholder={t.transactionDialog.selectCategory}
className="min-h-[44px] md:min-h-[40px] text-base md:text-sm"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="memo">{t.transactionDialog.memo}</Label> <Label htmlFor="memo" className="text-base md:text-sm">{t.transactionDialog.memo}</Label>
<Input <Input
id="memo" id="memo"
value={memo} value={memo}
onChange={(e) => setMemo(e.target.value)} onChange={(e) => setMemo(e.target.value)}
placeholder={t.transactionDialog.memoPlaceholder} placeholder={t.transactionDialog.addMemo}
className="h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
@@ -312,10 +315,10 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
)} )}
</div> </div>
<ResponsiveDialogFooter> <ResponsiveDialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}> <Button type="button" variant="outline" onClick={() => handleOpenChange(false)} className="h-11 md:h-9 text-base md:text-sm">
{t.common.cancel} {t.common.cancel}
</Button> </Button>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading} className="h-11 md:h-9 text-base md:text-sm">
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add} {loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
</Button> </Button>
</ResponsiveDialogFooter> </ResponsiveDialogFooter>

View File

@@ -149,22 +149,23 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
</ResponsiveDialogDescription> </ResponsiveDialogDescription>
</ResponsiveDialogHeader> </ResponsiveDialogHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4"> <div className="grid gap-4 p-4 md:py-4 md:px-0">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name">{t.walletDialog.name}</Label> <Label htmlFor="name" className="text-base md:text-sm">{t.walletDialog.name}</Label>
<Input <Input
id="name" id="name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder={t.walletDialog.namePlaceholder} placeholder={t.walletDialog.namePlaceholder}
className="h-11 md:h-9 text-base md:text-sm"
required required
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="kind">{t.walletDialog.type}</Label> <Label htmlFor="kind" className="text-base md:text-sm">{t.walletDialog.type}</Label>
<Select value={kind} onValueChange={(value: "money" | "asset") => setKind(value)}> <Select value={kind} onValueChange={(value: "money" | "asset") => setKind(value)}>
<SelectTrigger> <SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -176,14 +177,14 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
{kind === "money" ? ( {kind === "money" ? (
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="currency">{t.walletDialog.currency}</Label> <Label htmlFor="currency" className="text-base md:text-sm">{t.walletDialog.currency}</Label>
<Popover open={currencyOpen} onOpenChange={setCurrencyOpen}> <Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={currencyOpen} aria-expanded={currencyOpen}
className="w-full justify-between" className="w-full justify-between h-11 md:h-9 text-base md:text-sm"
> >
{currency {currency
? CURRENCIES.find((curr) => curr.code === currency)?.code + " - " + CURRENCIES.find((curr) => curr.code === currency)?.name ? CURRENCIES.find((curr) => curr.code === currency)?.code + " - " + CURRENCIES.find((curr) => curr.code === currency)?.name
@@ -205,6 +206,7 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
setCurrency(curr.code) setCurrency(curr.code)
setCurrencyOpen(false) setCurrencyOpen(false)
}} }}
className="min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
> >
<Check <Check
className={cn( className={cn(
@@ -224,37 +226,40 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
) : ( ) : (
<> <>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="unit">{t.walletDialog.unit}</Label> <Label htmlFor="unit" className="text-base md:text-sm">{t.walletDialog.unit}</Label>
<Input <Input
id="unit" id="unit"
value={unit} value={unit}
onChange={(e) => setUnit(e.target.value)} onChange={(e) => setUnit(e.target.value)}
placeholder={t.walletDialog.unitPlaceholder} placeholder={t.walletDialog.unitPlaceholder}
className="h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="pricePerUnit">{t.walletDialog.pricePerUnit}</Label> <Label htmlFor="pricePerUnit" className="text-base md:text-sm">{t.walletDialog.pricePerUnit}</Label>
<Input <Input
id="pricePerUnit" id="pricePerUnit"
type="number" type="number"
step="0.01" step="0.01"
value={pricePerUnit} value={pricePerUnit}
onChange={(e) => setPricePerUnit(e.target.value)} onChange={(e) => setPricePerUnit(e.target.value)}
placeholder={t.walletDialog.pricePerUnitPlaceholder} placeholder="0"
className="h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
</> </>
)} )}
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="initialAmount">{t.walletDialog.initialAmount}</Label> <Label htmlFor="initialAmount" className="text-base md:text-sm">{t.walletDialog.initialAmount}</Label>
<Input <Input
id="initialAmount" id="initialAmount"
type="number" type="number"
step="0.01" step="0.01"
value={initialAmount} value={initialAmount}
onChange={(e) => setInitialAmount(e.target.value)} onChange={(e) => setInitialAmount(e.target.value)}
placeholder={t.walletDialog.initialAmountPlaceholder} placeholder="0"
className="h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
@@ -265,10 +270,10 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
)} )}
</div> </div>
<ResponsiveDialogFooter> <ResponsiveDialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}> <Button type="button" variant="outline" onClick={() => handleOpenChange(false)} className="h-11 md:h-9 text-base md:text-sm">
{t.common.cancel} {t.common.cancel}
</Button> </Button>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading} className="h-11 md:h-9 text-base md:text-sm">
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add} {loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
</Button> </Button>
</ResponsiveDialogFooter> </ResponsiveDialogFooter>

View File

@@ -1,6 +1,5 @@
import { Home, Wallet, Receipt, User, LogOut } from "lucide-react" import { Home, Wallet, Receipt, User, LogOut } from "lucide-react"
import { Logo } from "../Logo" import { Logo } from "../Logo"
import { LanguageToggle } from "../LanguageToggle"
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -112,16 +111,13 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
</div> </div>
</div> </div>
</div> </div>
<div className="flex gap-2 mt-3">
<LanguageToggle />
<button <button
onClick={logout} onClick={logout}
className="flex-1 flex items-center justify-center px-4 py-2 text-sm font-medium text-destructive bg-destructive/10 rounded-lg hover:bg-destructive/20 transition-colors cursor-pointer" className="mt-3 w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-destructive bg-destructive/10 rounded-lg hover:bg-destructive/20 transition-colors cursor-pointer"
> >
<LogOut className="h-4 w-4 mr-2" /> <LogOut className="h-4 w-4 mr-2" />
{t.nav.logout} {t.nav.logout}
</button> </button>
</div>
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
) )

View File

@@ -1,15 +1,67 @@
import { useState } from "react"
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "./AppSidebar" import { AppSidebar } from "./AppSidebar"
import { ThemeToggle } from "@/components/ThemeToggle" import { ThemeToggle } from "@/components/ThemeToggle"
import { LanguageToggle } from "@/components/LanguageToggle"
import { Breadcrumb } from "@/components/Breadcrumb" import { Breadcrumb } from "@/components/Breadcrumb"
import { FloatingActionButton, FABTrendingUpIcon, FABWalletIcon, FABReceiptIcon } from "@/components/ui/floating-action-button"
import { AssetPriceUpdateDialog } from "@/components/dialogs/AssetPriceUpdateDialog"
import { WalletDialog } from "@/components/dialogs/WalletDialog"
import { TransactionDialog } from "@/components/dialogs/TransactionDialog"
import { useLanguage } from "@/contexts/LanguageContext"
interface DashboardLayoutProps { interface DashboardLayoutProps {
children: React.ReactNode children: React.ReactNode
currentPage: string currentPage: string
onNavigate: (page: string) => void onNavigate: (page: string) => void
onOpenWalletDialog?: () => void
onOpenTransactionDialog?: () => void
fabWalletDialogOpen: boolean
setFabWalletDialogOpen: (open: boolean) => void
fabTransactionDialogOpen: boolean
setFabTransactionDialogOpen: (open: boolean) => void
} }
export function DashboardLayout({ children, currentPage, onNavigate }: DashboardLayoutProps) { export function DashboardLayout({
children,
currentPage,
onNavigate,
onOpenWalletDialog,
onOpenTransactionDialog,
fabWalletDialogOpen,
setFabWalletDialogOpen,
fabTransactionDialogOpen,
setFabTransactionDialogOpen
}: DashboardLayoutProps) {
const { t } = useLanguage()
const [assetPriceDialogOpen, setAssetPriceDialogOpen] = useState(false)
const handleDialogSuccess = () => {
// Reload page to refresh data
window.location.reload()
}
const fabActions = [
{
icon: <FABTrendingUpIcon className="h-5 w-5" />,
label: t.fab.updateAssetPrices,
onClick: () => setAssetPriceDialogOpen(true),
variant: "default" as const,
},
{
icon: <FABReceiptIcon className="h-5 w-5" />,
label: t.fab.quickTransaction,
onClick: () => onOpenTransactionDialog?.(),
variant: "secondary" as const,
},
{
icon: <FABWalletIcon className="h-5 w-5" />,
label: t.fab.quickWallet,
onClick: () => onOpenWalletDialog?.(),
variant: "secondary" as const,
},
]
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar currentPage={currentPage} onNavigate={onNavigate} /> <AppSidebar currentPage={currentPage} onNavigate={onNavigate} />
@@ -27,7 +79,10 @@ export function DashboardLayout({ children, currentPage, onNavigate }: Dashboard
year: 'numeric', year: 'numeric',
})} })}
</span> </span>
<div className="flex items-center gap-2">
<LanguageToggle />
<ThemeToggle /> <ThemeToggle />
</div>
</header> </header>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<div className="container mx-auto max-w-7xl p-4"> <div className="container mx-auto max-w-7xl p-4">
@@ -35,6 +90,25 @@ export function DashboardLayout({ children, currentPage, onNavigate }: Dashboard
</div> </div>
</div> </div>
</div> </div>
{/* Floating Action Button */}
<FloatingActionButton actions={fabActions} />
{/* FAB Dialogs */}
<AssetPriceUpdateDialog
open={assetPriceDialogOpen}
onOpenChange={setAssetPriceDialogOpen}
/>
<WalletDialog
open={fabWalletDialogOpen}
onOpenChange={setFabWalletDialogOpen}
onSuccess={handleDialogSuccess}
/>
<TransactionDialog
open={fabTransactionDialogOpen}
onOpenChange={setFabTransactionDialogOpen}
onSuccess={handleDialogSuccess}
/>
</main> </main>
</SidebarProvider> </SidebarProvider>
) )

View File

@@ -3,6 +3,7 @@ import { useLanguage } from "@/contexts/LanguageContext"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label"
import { Plus, Wallet, TrendingUp, TrendingDown, Calendar } from "lucide-react" import { Plus, Wallet, TrendingUp, TrendingDown, Calendar } from "lucide-react"
import { ChartContainer, ChartTooltip } from "@/components/ui/chart" import { ChartContainer, ChartTooltip } from "@/components/ui/chart"
import { Bar, XAxis, YAxis, ResponsiveContainer, PieChart as RechartsPieChart, Pie, Cell, Line, ComposedChart } from "recharts" import { Bar, XAxis, YAxis, ResponsiveContainer, PieChart as RechartsPieChart, Pie, Cell, Line, ComposedChart } from "recharts"
@@ -14,6 +15,11 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { DatePicker } from "@/components/ui/date-picker" import { DatePicker } from "@/components/ui/date-picker"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { import {
Table, Table,
TableBody, TableBody,
@@ -117,20 +123,30 @@ function getDateRangeLabel(dateRange: DateRange, customStartDate?: Date, customE
} }
// Helper function to format Y-axis values with k/m suffix // Helper function to format Y-axis values with k/m suffix
function formatYAxisValue(value: number): string { function formatYAxisValue(value: number, language: string = 'en'): string {
const absValue = Math.abs(value) const absValue = Math.abs(value)
const sign = value < 0 ? '-' : '' const sign = value < 0 ? '-' : ''
if (absValue >= 1000000) { // Get suffix based on language
return `${sign}${(absValue / 1000000).toFixed(1)}m` const getSuffix = (type: 'thousand' | 'million' | 'billion') => {
if (language === 'id') {
return { thousand: 'rb', million: 'jt', billion: 'm' }[type]
}
return { thousand: 'k', million: 'm', billion: 'b' }[type]
}
if (absValue >= 1000000000) {
return `${sign}${(absValue / 1000000000).toFixed(1)}${getSuffix('billion')}`
} else if (absValue >= 1000000) {
return `${sign}${(absValue / 1000000).toFixed(1)}${getSuffix('million')}`
} else if (absValue >= 1000) { } else if (absValue >= 1000) {
return `${sign}${(absValue / 1000).toFixed(1)}k` return `${sign}${(absValue / 1000).toFixed(1)}${getSuffix('thousand')}`
} }
return value.toLocaleString() return value.toLocaleString()
} }
export function Overview() { export function Overview() {
const { t } = useLanguage() const { t, language } = useLanguage()
const [wallets, setWallets] = useState<Wallet[]>([]) const [wallets, setWallets] = useState<Wallet[]>([])
const [transactions, setTransactions] = useState<Transaction[]>([]) const [transactions, setTransactions] = useState<Transaction[]>([])
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({}) const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({})
@@ -309,10 +325,14 @@ export function Overview() {
for (let i = periodsCount - 1; i >= 0; i--) { for (let i = periodsCount - 1; i >= 0; i--) {
const date = new Date(now) const date = new Date(now)
date.setDate(date.getDate() - i) date.setDate(date.getDate() - i)
const locale = language === 'id' ? 'id-ID' : 'en-US'
const label = language === 'id'
? `${date.getDate()} ${date.toLocaleDateString(locale, { month: 'short' })}`
: date.toLocaleDateString(locale, { month: 'short', day: 'numeric' })
periods.push({ periods.push({
start: new Date(date.getFullYear(), date.getMonth(), date.getDate()), start: new Date(date.getFullYear(), date.getMonth(), date.getDate()),
end: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59), end: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59),
label: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) label
}) })
} }
break break
@@ -327,10 +347,15 @@ export function Overview() {
weekEnd.setDate(weekStart.getDate() + 6) weekEnd.setDate(weekStart.getDate() + 6)
weekEnd.setHours(23, 59, 59) weekEnd.setHours(23, 59, 59)
const locale = language === 'id' ? 'id-ID' : 'en-US'
const label = language === 'id'
? `${weekStart.getDate()} ${weekStart.toLocaleDateString(locale, { month: 'short' })}`
: weekStart.toLocaleDateString(locale, { month: 'short', day: 'numeric' })
periods.push({ periods.push({
start: weekStart, start: weekStart,
end: weekEnd, end: weekEnd,
label: `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}` label
}) })
} }
break break
@@ -341,10 +366,11 @@ export function Overview() {
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1) const monthStart = new Date(date.getFullYear(), date.getMonth(), 1)
const monthEnd = new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59) const monthEnd = new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59)
const locale = language === 'id' ? 'id-ID' : 'en-US'
periods.push({ periods.push({
start: monthStart, start: monthStart,
end: monthEnd, end: monthEnd,
label: date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }) label: date.toLocaleDateString(locale, { month: 'short', year: '2-digit' })
}) })
} }
break break
@@ -498,8 +524,8 @@ export function Overview() {
if (loading) { if (loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 lg:grid-cols-3">
{[...Array(4)].map((_, i) => ( {[...Array(3)].map((_, i) => (
<Card key={i}> <Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="h-4 w-20 bg-gray-200 rounded animate-pulse" /> <div className="h-4 w-20 bg-gray-200 rounded animate-pulse" />
@@ -518,7 +544,7 @@ export function Overview() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="space-y-4"> <div className="flex flex-col md:flex-row md:justify-between md:items-end gap-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">{t.overview.title}</h1> <h1 className="text-3xl font-bold tracking-tight">{t.overview.title}</h1>
@@ -529,15 +555,15 @@ export function Overview() {
</div> </div>
{/* Date Range Filter */} {/* Date Range Filter */}
<div className="space-y-3 flex flex-col md:flex-row md:flex-wrap md:justify-between gap-3 md:w-full"> <div className="space-y-3 flex flex-col items-end">
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3 w-full">
<label className="text-xs font-medium text-muted-foreground flex flex-row flex-nowrap items-center gap-1"> <label className="text-xs font-medium text-muted-foreground flex flex-row flex-nowrap items-center gap-1">
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{t.overview.overviewPeriod}</span> <span className="text-sm font-medium">{t.overview.overviewPeriod}</span>
</label> </label>
<Select value={dateRange} onValueChange={(value: DateRange) => setDateRange(value)}> <Select value={dateRange} onValueChange={(value: DateRange) => setDateRange(value)}>
<SelectTrigger className="w-full sm:w-[180px]"> <SelectTrigger className="w-full sm:w-[180px] h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.overview.overviewPeriodPlaceholder} /> <SelectValue placeholder={t.overview.overviewPeriodPlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -550,36 +576,41 @@ export function Overview() {
</SelectContent> </SelectContent>
</Select> </Select>
{/* Custom Date Fields */} {/* Custom Date Range Popover */}
{dateRange === 'custom' && ( {dateRange === 'custom' && (
<div className="flex gap-3"> <Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-11 md:h-9 text-base md:text-sm">
<Calendar className="mr-2 h-4 w-4" />
{customStartDate && customEndDate
? `${customStartDate.toLocaleDateString()} - ${customEndDate.toLocaleDateString()}`
: t.overview.selectDateRange}
</Button>
</PopoverTrigger>
<PopoverContent className="w-full md:w-auto p-4" align="end">
<div className="space-y-3 md:grid md:grid-cols-2 md:gap-3">
<div>
<Label className="text-sm font-medium mb-2 block">{t.dateRange.from}</Label>
<DatePicker <DatePicker
date={customStartDate} date={customStartDate}
onDateChange={setCustomStartDate} onDateChange={setCustomStartDate}
placeholder={t.overview.customStartDatePlaceholder} placeholder={t.overview.customStartDatePlaceholder}
className="w-50 sm:w-[200px]" className="w-full h-11 md:h-9 text-base md:text-sm"
/> />
</div>
<div>
<Label className="text-sm font-medium mb-2 block">{t.dateRange.to}</Label>
<DatePicker <DatePicker
date={customEndDate} date={customEndDate}
onDateChange={setCustomEndDate} onDateChange={setCustomEndDate}
placeholder={t.overview.customEndDatePlaceholder} placeholder={t.overview.customEndDatePlaceholder}
className="w-50 sm:w-[200px]" className="w-full h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
)}
</div> </div>
</PopoverContent>
<hr className="my-2 block sm:hidden" /> </Popover>
)}
<div className="w-full md:w-fit grid grid-cols-2 gap-3">
<Button onClick={() => setWalletDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t.overview.addWallet}
</Button>
<Button variant="outline" onClick={() => setTransactionDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t.overview.addTransaction}
</Button>
</div> </div>
</div> </div>
@@ -647,10 +678,10 @@ export function Overview() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t.overview.walletTheadName}</TableHead> <TableHead>{t.overview.walletTheadName}</TableHead>
<TableHead className="text-center">{t.overview.walletTheadCurrencyUnit}</TableHead> <TableHead className="text-center text-nowrap">{t.overview.walletTheadCurrencyUnit}</TableHead>
<TableHead className="text-center">{t.overview.walletTheadTransactions}</TableHead> <TableHead className="text-center text-nowrap">{t.overview.walletTheadTransactions}</TableHead>
<TableHead className="text-right">{t.overview.walletTheadTotalBalance}</TableHead> <TableHead className="text-right text-nowrap">{t.overview.walletTheadTotalBalance}</TableHead>
<TableHead className="text-right">{t.overview.walletTheadDomination}</TableHead> <TableHead className="text-right text-nowrap">{t.overview.walletTheadDomination}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -726,7 +757,7 @@ export function Overview() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span>{t.overview.incomeCategoryFor} {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span> <span>{t.overview.incomeCategoryFor} {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span>
<Select value={incomeChartWallet} onValueChange={setIncomeChartWallet}> <Select value={incomeChartWallet} onValueChange={setIncomeChartWallet}>
<SelectTrigger className="w-full max-w-[180px]"> <SelectTrigger className="w-full max-w-[180px] h-11 md:h-9 text-base md:text-sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -857,7 +888,7 @@ export function Overview() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span>{t.overview.expenseCategoryFor} {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span> <span>{t.overview.expenseCategoryFor} {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span>
<Select value={expenseChartWallet} onValueChange={setExpenseChartWallet}> <Select value={expenseChartWallet} onValueChange={setExpenseChartWallet}>
<SelectTrigger className="w-full max-w-[180px]"> <SelectTrigger className="w-full max-w-[180px] h-11 md:h-9 text-base md:text-sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -990,7 +1021,7 @@ export function Overview() {
<div className="flex flex-col sm:flex-row sm:items-center gap-2"> <div className="flex flex-col sm:flex-row sm:items-center gap-2">
<span>{t.overview.financialTrendDescription}</span> <span>{t.overview.financialTrendDescription}</span>
<Select value={trendPeriod} onValueChange={(value: TrendPeriod) => setTrendPeriod(value)}> <Select value={trendPeriod} onValueChange={(value: TrendPeriod) => setTrendPeriod(value)}>
<SelectTrigger className="w-full max-w-[140px]"> <SelectTrigger className="w-full max-w-[140px] h-11 md:h-9 text-base md:text-sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1029,9 +1060,10 @@ export function Overview() {
tickMargin={8} tickMargin={8}
/> />
<YAxis <YAxis
tickFormatter={formatYAxisValue} tickFormatter={(value) => formatYAxisValue(value, language)}
fontSize={12} fontSize={12}
width={60} width={60}
domain={['auto', 'auto']}
/> />
<ChartTooltip <ChartTooltip
content={({ active, payload, label }) => { content={({ active, payload, label }) => {

View File

@@ -20,8 +20,6 @@ import {
Copy, Copy,
Check, Check,
User, User,
UserCircle,
Lock,
Upload, Upload,
Trash2 Trash2
} from "lucide-react" } from "lucide-react"
@@ -58,7 +56,6 @@ export function Profile() {
const [editedName, setEditedName] = useState("") const [editedName, setEditedName] = useState("")
const [nameLoading, setNameLoading] = useState(false) const [nameLoading, setNameLoading] = useState(false)
const [nameError, setNameError] = useState("") const [nameError, setNameError] = useState("")
const [nameSuccess, setNameSuccess] = useState("")
// Avatar upload // Avatar upload
const [avatarUploading, setAvatarUploading] = useState(false) const [avatarUploading, setAvatarUploading] = useState(false)
@@ -74,7 +71,6 @@ export function Profile() {
const [phone, setPhone] = useState("") const [phone, setPhone] = useState("")
const [phoneLoading, setPhoneLoading] = useState(false) const [phoneLoading, setPhoneLoading] = useState(false)
const [phoneError, setPhoneError] = useState("") const [phoneError, setPhoneError] = useState("")
const [phoneSuccess, setPhoneSuccess] = useState("")
// Email OTP states // Email OTP states
const [emailOtpCode, setEmailOtpCode] = useState("") const [emailOtpCode, setEmailOtpCode] = useState("")
@@ -98,7 +94,6 @@ export function Profile() {
const [confirmPassword, setConfirmPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("")
const [passwordLoading, setPasswordLoading] = useState(false) const [passwordLoading, setPasswordLoading] = useState(false)
const [passwordError, setPasswordError] = useState("") const [passwordError, setPasswordError] = useState("")
const [passwordSuccess, setPasswordSuccess] = useState("")
useEffect(() => { useEffect(() => {
loadOtpStatus() loadOtpStatus()
@@ -165,23 +160,22 @@ export function Profile() {
try { try {
setNameLoading(true) setNameLoading(true)
setNameError("") setNameError("")
setNameSuccess("")
if (!editedName || editedName.trim().length === 0) { if (!editedName || editedName.trim().length === 0) {
setNameError("Name cannot be empty") setNameError(t.profile.nameError)
return return
} }
await axios.put(`${API}/users/profile`, { name: editedName }) await axios.put(`${API}/users/profile`, { name: editedName })
toast.success('Nama berhasil diupdate') toast.success(t.profile.nameSuccess)
setNameSuccess("Name updated successfully!")
setIsEditingName(false) setIsEditingName(false)
// Reload user data // Reload user data
window.location.reload() window.location.reload()
} catch (error) { } catch (error) {
const err = error as { response?: { data?: { message?: string } } } const err = error as { response?: { data?: { message?: string } } }
setNameError(err.response?.data?.message || "Failed to update name") setNameError(err.response?.data?.message || t.profile.nameLoadingError)
toast.error(err.response?.data?.message || t.profile.nameLoadingError)
} finally { } finally {
setNameLoading(false) setNameLoading(false)
} }
@@ -216,7 +210,7 @@ export function Profile() {
} }
}) })
toast.success('Avatar berhasil diupdate') toast.success(t.profile.avatarSuccess)
// Reload user data to get new avatar URL // Reload user data to get new avatar URL
window.location.reload() window.location.reload()
} catch (error) { } catch (error) {
@@ -233,7 +227,7 @@ export function Profile() {
setDeleteError("") setDeleteError("")
if (!deletePassword) { if (!deletePassword) {
setDeleteError("Please enter your password") setDeleteError(t.profile.enterPassword)
return return
} }
@@ -241,13 +235,13 @@ export function Profile() {
data: { password: deletePassword } data: { password: deletePassword }
}) })
toast.success('Akun berhasil dihapus') toast.success(t.profile.deleteSuccess)
// Logout and redirect to login // Logout and redirect to login
localStorage.removeItem('token') localStorage.removeItem('token')
window.location.href = '/auth/login' window.location.href = '/auth/login'
} catch (error) { } catch (error) {
const err = error as { response?: { data?: { message?: string } } } const err = error as { response?: { data?: { message?: string } } }
setDeleteError(err.response?.data?.message || "Failed to delete account") toast.error(err.response?.data?.message || t.profile.deleteError)
} finally { } finally {
setDeleteLoading(false) setDeleteLoading(false)
} }
@@ -257,35 +251,33 @@ export function Profile() {
try { try {
setPhoneLoading(true) setPhoneLoading(true)
setPhoneError("") setPhoneError("")
setPhoneSuccess("")
if (!phone || phone.length < 10) { if (!phone || phone.length < 10) {
setPhoneError(t.profile.phoneNumber + " tidak valid") setPhoneError(t.profile.phoneInvalid)
return return
} }
// Check if number is registered on WhatsApp using webhook // Check if number is registered on WhatsApp
const checkResponse = await axios.post(`${API}/otp/send`, { const checkResponse = await axios.post(`${API}/otp/whatsapp/check`, {
method: 'whatsapp', phone: phone
mode: 'check_number',
to: phone
}) })
if (checkResponse.data.code === 'SUCCESS' && checkResponse.data.results?.is_on_whatsapp === false) { // If check failed or number not registered, show error
setPhoneError("Nomor ini tidak terdaftar di WhatsApp. Silakan coba nomor lain.") if (!checkResponse.data.success || !checkResponse.data.isRegistered) {
setPhoneError(checkResponse.data.message || t.profile.phoneNotRegistered)
return return
} }
// Update phone // Update phone
await axios.put(`${API}/users/profile`, { phone }) await axios.put(`${API}/users/profile`, { phone })
toast.success(t.profile.phoneNumber + ' berhasil diupdate') toast.success(t.profile.phoneSuccess)
setPhoneSuccess(t.profile.phoneNumber + " updated successfully!")
// Reload OTP status // Reload OTP status
await loadOtpStatus() await loadOtpStatus()
} catch (error) { } catch (error) {
const err = error as { response?: { data?: { message?: string } } } const err = error as { response?: { data?: { message?: string } } }
setPhoneError(err.response?.data?.message || "Failed to update phone number") setPhoneError(err.response?.data?.message || t.profile.phoneError)
toast.error(err.response?.data?.message || t.profile.phoneError)
} finally { } finally {
setPhoneLoading(false) setPhoneLoading(false)
} }
@@ -295,7 +287,7 @@ export function Profile() {
try { try {
setEmailOtpLoading(true) setEmailOtpLoading(true)
await axios.post(`${API}/otp/email/send`) await axios.post(`${API}/otp/email/send`)
toast.success('Kode OTP telah dikirim ke email') toast.success(t.profile.emailOtpSent)
setEmailOtpSent(true) setEmailOtpSent(true)
} catch (error) { } catch (error) {
console.error('Failed to send email OTP:', error) console.error('Failed to send email OTP:', error)
@@ -308,7 +300,7 @@ export function Profile() {
try { try {
setEmailOtpLoading(true) setEmailOtpLoading(true)
await axios.post(`${API}/otp/email/verify`, { code: emailOtpCode }) await axios.post(`${API}/otp/email/verify`, { code: emailOtpCode })
toast.success('Email OTP berhasil diaktifkan') toast.success(t.profile.emailOtpEnabled)
await loadOtpStatus() await loadOtpStatus()
setEmailOtpCode("") setEmailOtpCode("")
setEmailOtpSent(false) setEmailOtpSent(false)
@@ -323,7 +315,7 @@ export function Profile() {
try { try {
setEmailOtpLoading(true) setEmailOtpLoading(true)
await axios.post(`${API}/otp/email/disable`) await axios.post(`${API}/otp/email/disable`)
toast.success('Email OTP berhasil dinonaktifkan') toast.success(t.profile.emailOtpDisabled)
await loadOtpStatus() await loadOtpStatus()
} catch (error) { } catch (error) {
console.error('Failed to disable email OTP:', error) console.error('Failed to disable email OTP:', error)
@@ -336,7 +328,7 @@ export function Profile() {
try { try {
setWhatsappOtpLoading(true) setWhatsappOtpLoading(true)
await axios.post(`${API}/otp/whatsapp/send`, { mode: 'test' }) await axios.post(`${API}/otp/whatsapp/send`, { mode: 'test' })
toast.success('Kode OTP telah dikirim ke WhatsApp') toast.success(t.profile.whatsappOtpSent)
setWhatsappOtpSent(true) setWhatsappOtpSent(true)
} catch (error) { } catch (error) {
console.error('Failed to send WhatsApp OTP:', error) console.error('Failed to send WhatsApp OTP:', error)
@@ -349,7 +341,7 @@ export function Profile() {
try { try {
setWhatsappOtpLoading(true) setWhatsappOtpLoading(true)
await axios.post(`${API}/otp/whatsapp/verify`, { code: whatsappOtpCode }) await axios.post(`${API}/otp/whatsapp/verify`, { code: whatsappOtpCode })
toast.success('WhatsApp OTP berhasil diaktifkan') toast.success(t.profile.whatsappOtpEnabled)
await loadOtpStatus() await loadOtpStatus()
setWhatsappOtpCode("") setWhatsappOtpCode("")
setWhatsappOtpSent(false) setWhatsappOtpSent(false)
@@ -364,7 +356,7 @@ export function Profile() {
try { try {
setWhatsappOtpLoading(true) setWhatsappOtpLoading(true)
await axios.post(`${API}/otp/whatsapp/disable`) await axios.post(`${API}/otp/whatsapp/disable`)
toast.success('WhatsApp OTP berhasil dinonaktifkan') toast.success(t.profile.whatsappOtpDisabled)
await loadOtpStatus() await loadOtpStatus()
} catch (error) { } catch (error) {
console.error('Failed to disable WhatsApp OTP:', error) console.error('Failed to disable WhatsApp OTP:', error)
@@ -394,7 +386,7 @@ export function Profile() {
try { try {
setTotpLoading(true) setTotpLoading(true)
await axios.post(`${API}/otp/totp/verify`, { code: totpCode }) await axios.post(`${API}/otp/totp/verify`, { code: totpCode })
toast.success('Authenticator App berhasil diaktifkan') toast.success(t.profile.totpEnabled)
await loadOtpStatus() await loadOtpStatus()
setTotpCode("") setTotpCode("")
setShowTotpSetup(false) setShowTotpSetup(false)
@@ -409,7 +401,7 @@ export function Profile() {
try { try {
setTotpLoading(true) setTotpLoading(true)
await axios.post(`${API}/otp/totp/disable`) await axios.post(`${API}/otp/totp/disable`)
toast.success('Authenticator App berhasil dinonaktifkan') toast.success(t.profile.totpDisabled)
await loadOtpStatus() await loadOtpStatus()
setShowTotpSetup(false) setShowTotpSetup(false)
// Clear QR code and secret when disabling // Clear QR code and secret when disabling
@@ -435,30 +427,29 @@ export function Profile() {
const handleChangePassword = async () => { const handleChangePassword = async () => {
setPasswordError("") setPasswordError("")
setPasswordSuccess("")
// Validation // Validation
if (!hasPassword) { if (!hasPassword) {
// For users without password: only need new password and confirmation // For users without password: only need new password and confirmation
if (!newPassword || !confirmPassword) { if (!newPassword || !confirmPassword) {
setPasswordError("Please enter and confirm your new password") setPasswordError(t.profile.enterPassword)
return return
} }
} else { } else {
// For users with password: need current password too // For users with password: need current password too
if (!currentPassword || !newPassword || !confirmPassword) { if (!currentPassword || !newPassword || !confirmPassword) {
setPasswordError("All fields are required") setPasswordError(t.profile.enterPassword)
return return
} }
} }
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setPasswordError("New passwords do not match") setPasswordError("Password tidak cocok")
return return
} }
if (newPassword.length < 6) { if (newPassword.length < 6) {
setPasswordError("New password must be at least 6 characters") setPasswordError("Password minimal 6 karakter")
return return
} }
@@ -472,9 +463,7 @@ export function Profile() {
newPassword, newPassword,
isSettingPassword: true // Flag to tell backend this is initial password isSettingPassword: true // Flag to tell backend this is initial password
}) })
setPasswordSuccess("Password set successfully! You can now login with email/password.") toast.success(t.profile.passwordSetSuccess)
toast.success('Password berhasil diatur')
setPasswordSuccess("Password set successfully! Redirecting...")
setTimeout(() => window.location.reload(), 2000) setTimeout(() => window.location.reload(), 2000)
} else { } else {
// Change password for user with existing password // Change password for user with existing password
@@ -482,18 +471,18 @@ export function Profile() {
currentPassword, currentPassword,
newPassword newPassword
}) })
toast.success('Password berhasil diubah') toast.success(t.profile.passwordChangeSuccess)
setPasswordSuccess("Password changed successfully!")
} }
setCurrentPassword("") setCurrentPassword("")
setNewPassword("") setNewPassword("")
setConfirmPassword("") setConfirmPassword("")
await loadOtpStatus() await loadOtpStatus()
setTimeout(() => setPasswordSuccess(""), 3000)
} catch (error) { } catch (error) {
const err = error as { response?: { data?: { message?: string } } } const err = error as { response?: { data?: { message?: string } } }
setPasswordError(err.response?.data?.message || (!hasPassword ? "Failed to set password" : "Failed to change password")) const errorMsg = err.response?.data?.message || t.profile.passwordError
setPasswordError(errorMsg)
toast.error(errorMsg)
} finally { } finally {
setPasswordLoading(false) setPasswordLoading(false)
} }
@@ -509,27 +498,24 @@ export function Profile() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold">{t.profile.title}</h1> <h1 className="text-3xl font-bold">{t.profile.title}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">{t.profile.description}</p>
{t.profile.description}
</p>
</div> </div>
<Tabs defaultValue="profile" className="space-y-6"> <div className="max-w-4xl mx-auto">
<TabsList className="grid w-full grid-cols-2 max-w-md"> <Tabs defaultValue="profile" className="w-full">
<TabsTrigger value="profile" className="flex items-center gap-2"> <TabsList className="grid w-[50%] grid-cols-2 h-auto p-1">
<UserCircle className="h-4 w-4" /> <TabsTrigger value="profile" className="h-11 md:h-9 text-base md:text-sm data-[state=active]:bg-background">
{t.profile.editProfile} {t.profile.editProfile}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-2"> <TabsTrigger value="security" className="h-11 md:h-9 text-base md:text-sm data-[state=active]:bg-background">
<Lock className="h-4 w-4" />
{t.profile.security} {t.profile.security}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* Edit Profile Tab */} {/* Edit Profile Tab */}
<TabsContent value="profile" className="space-y-6"> <TabsContent value="profile" className="w-full space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t.profile.personalInfo}</CardTitle> <CardTitle>{t.profile.personalInfo}</CardTitle>
@@ -592,16 +578,16 @@ export function Profile() {
<Separator /> <Separator />
{/* Name Field */} {/* Name Field */}
<div className="space-y-2"> <div className="space-y-3 md:space-y-2">
<Label htmlFor="name">{t.profile.name}</Label> <Label htmlFor="name" className="text-base md:text-sm">{t.profile.name}</Label>
{hasGoogleAuth ? ( {!isEditingName ? (
<> <>
<Input <Input
id="name" id="name"
type="text" type="text"
value={user?.name || ""} value={user?.name || ''}
disabled disabled
className="bg-muted" className="bg-muted h-11 md:h-9 text-base md:text-sm"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t.profile.nameSynced} {t.profile.nameSynced}
@@ -611,23 +597,23 @@ export function Profile() {
<> <>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="name" id="name-edit"
type="text" type="text"
value={isEditingName ? editedName : (user?.name || "")} value={editedName}
onChange={(e) => setEditedName(e.target.value)} onChange={(e) => setEditedName(e.target.value)}
disabled={!isEditingName || nameLoading} disabled={nameLoading}
className={!isEditingName ? "bg-muted" : ""} className="h-11 md:h-9 text-base md:text-sm"
/> />
{isEditingName ? (
<>
<Button <Button
type="button"
onClick={handleUpdateName} onClick={handleUpdateName}
disabled={nameLoading} disabled={nameLoading}
size="sm" className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm min-w-[100px]"
> >
{nameLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.save} {nameLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.save}
</Button> </Button>
<Button <Button
type="button"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setIsEditingName(false) setIsEditingName(false)
@@ -635,80 +621,61 @@ export function Profile() {
setNameError("") setNameError("")
}} }}
disabled={nameLoading} disabled={nameLoading}
size="sm" className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm min-w-[100px]"
> >
{t.profile.cancel} {t.profile.cancel}
</Button> </Button>
</>
) : (
<Button
variant="outline"
onClick={() => setIsEditingName(true)}
size="sm"
>
{t.profile.edit}
</Button>
)}
</div> </div>
{nameError && ( {nameError && (
<p className="text-xs text-destructive">{nameError}</p> <p className="text-xs text-destructive">{nameError}</p>
)} )}
{nameSuccess && (
<p className="text-xs text-green-600">{nameSuccess}</p>
)}
</> </>
)} )}
</div> </div>
{/* Email Field */} {/* Email Field */}
<div className="space-y-2"> <div className="space-y-3 md:space-y-2">
<Label htmlFor="email">{t.profile.email}</Label> <Label htmlFor="email" className="text-base md:text-sm">{t.profile.email}</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
value={user?.email || ""} value={user?.email || ''}
disabled disabled
className="bg-muted" className="bg-muted h-11 md:h-9 text-base md:text-sm"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t.profile.emailCannotBeChanged} {t.profile.emailCannotBeChanged}
</p> </p>
</div> </div>
{/* Phone Field */} {/* Phone Number Field */}
<div className="space-y-2"> <div className="space-y-3 md:space-y-2">
<Label htmlFor="phone">{t.profile.phoneNumber}</Label> <Label htmlFor="phone" className="text-base md:text-sm">{t.profile.phoneNumber}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="phone" id="phone"
type="tel" type="tel"
placeholder="+1234567890"
value={phone} value={phone}
onChange={(e) => setPhone(e.target.value)} onChange={(e) => setPhone(e.target.value)}
placeholder={t.profile.phoneNumberPlaceholder}
disabled={phoneLoading} disabled={phoneLoading}
className="h-11 md:h-9 text-base md:text-sm"
/> />
<Button <Button
type="button"
onClick={handleUpdatePhone} onClick={handleUpdatePhone}
disabled={phoneLoading || !phone} disabled={phoneLoading}
className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm min-w-[120px]"
> >
{phoneLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.update} {phoneLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.update}
</Button> </Button>
</div> </div>
{phoneError && ( {phoneError && (
<Alert variant="destructive"> <Alert>
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription>{phoneError}</AlertDescription> <AlertDescription>{phoneError}</AlertDescription>
</Alert> </Alert>
)} )}
{phoneSuccess && (
<Alert>
<Check className="h-4 w-4" />
<AlertDescription>{phoneSuccess}</AlertDescription>
</Alert>
)}
<p className="text-xs text-muted-foreground">
{t.profile.phoneNumberDescription}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -747,15 +714,9 @@ export function Profile() {
<AlertDescription>{passwordError}</AlertDescription> <AlertDescription>{passwordError}</AlertDescription>
</Alert> </Alert>
)} )}
{passwordSuccess && (
<Alert className="bg-green-50 text-green-900 border-green-200">
<Check className="h-4 w-4" />
<AlertDescription>{passwordSuccess}</AlertDescription>
</Alert>
)}
{hasPassword && ( {hasPassword && (
<div> <div>
<Label htmlFor="current-password">{t.profile.currentPassword}</Label> <Label htmlFor="current-password" className="text-base md:text-sm">{t.profile.currentPassword}</Label>
<Input <Input
id="current-password" id="current-password"
type="password" type="password"
@@ -763,11 +724,12 @@ export function Profile() {
value={currentPassword} value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)} onChange={(e) => setCurrentPassword(e.target.value)}
disabled={passwordLoading} disabled={passwordLoading}
className="h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
)} )}
<div> <div>
<Label htmlFor="new-password">{t.profile.newPassword}</Label> <Label htmlFor="new-password" className="text-base md:text-sm">{t.profile.newPassword}</Label>
<Input <Input
id="new-password" id="new-password"
type="password" type="password"
@@ -775,10 +737,11 @@ export function Profile() {
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
disabled={passwordLoading} disabled={passwordLoading}
className="h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
<div> <div>
<Label htmlFor="confirm-password">{t.profile.confirmPassword}</Label> <Label htmlFor="confirm-password" className="text-base md:text-sm">{t.profile.confirmPassword}</Label>
<Input <Input
id="confirm-password" id="confirm-password"
type="password" type="password"
@@ -786,10 +749,11 @@ export function Profile() {
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
disabled={passwordLoading} disabled={passwordLoading}
className="h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
<Button <Button
className="w-full" className="w-full h-11 md:h-9 text-base md:text-sm"
onClick={handleChangePassword} onClick={handleChangePassword}
disabled={passwordLoading} disabled={passwordLoading}
> >
@@ -856,7 +820,7 @@ export function Profile() {
<Button <Button
onClick={handleWhatsappOtpRequest} onClick={handleWhatsappOtpRequest}
disabled={whatsappOtpLoading} disabled={whatsappOtpLoading}
className="w-full" className="w-full h-11 md:h-9 text-base md:text-sm"
> >
{whatsappOtpLoading ? ( {whatsappOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" /> <RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -872,7 +836,7 @@ export function Profile() {
{t.profile.checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode} {t.profile.checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Label htmlFor="whatsapp-otp">{t.profile.enterVerificationCode}</Label> <Label htmlFor="whatsapp-otp" className="text-base md:text-sm">{t.profile.enterVerificationCode}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="whatsapp-otp" id="whatsapp-otp"
@@ -881,10 +845,12 @@ export function Profile() {
value={whatsappOtpCode} value={whatsappOtpCode}
onChange={(e) => setWhatsappOtpCode(e.target.value)} onChange={(e) => setWhatsappOtpCode(e.target.value)}
maxLength={6} maxLength={6}
className="h-11 md:h-9 text-base md:text-sm"
/> />
<Button <Button
onClick={handleWhatsappOtpVerify} onClick={handleWhatsappOtpVerify}
disabled={whatsappOtpLoading || whatsappOtpCode.length !== 6} disabled={whatsappOtpLoading || whatsappOtpCode.length !== 6}
className="h-11 md:h-9 text-base md:text-sm min-w-[100px]"
> >
{whatsappOtpLoading ? ( {whatsappOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin" /> <RefreshCw className="h-4 w-4 animate-spin" />
@@ -903,7 +869,7 @@ export function Profile() {
variant="destructive" variant="destructive"
onClick={handleWhatsappOtpDisable} onClick={handleWhatsappOtpDisable}
disabled={whatsappOtpLoading} disabled={whatsappOtpLoading}
className="w-full" className="w-full h-11 md:h-9 text-base md:text-sm"
> >
{whatsappOtpLoading ? ( {whatsappOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" /> <RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -940,7 +906,7 @@ export function Profile() {
<Button <Button
onClick={handleEmailOtpRequest} onClick={handleEmailOtpRequest}
disabled={emailOtpLoading} disabled={emailOtpLoading}
className="w-full" className="w-full h-11 md:h-9 text-base md:text-sm"
> >
{emailOtpLoading ? ( {emailOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" /> <RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -960,10 +926,12 @@ export function Profile() {
value={emailOtpCode} value={emailOtpCode}
onChange={(e) => setEmailOtpCode(e.target.value)} onChange={(e) => setEmailOtpCode(e.target.value)}
maxLength={6} maxLength={6}
className="h-11 md:h-9 text-base md:text-sm"
/> />
<Button <Button
onClick={handleEmailOtpVerify} onClick={handleEmailOtpVerify}
disabled={emailOtpLoading || emailOtpCode.length !== 6} disabled={emailOtpLoading || emailOtpCode.length !== 6}
className="h-11 md:h-9 text-base md:text-sm min-w-[100px]"
> >
{emailOtpLoading ? ( {emailOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin" /> <RefreshCw className="h-4 w-4 animate-spin" />
@@ -980,6 +948,7 @@ export function Profile() {
variant="destructive" variant="destructive"
onClick={handleEmailOtpDisable} onClick={handleEmailOtpDisable}
disabled={emailOtpLoading} disabled={emailOtpLoading}
className="w-full h-11 md:h-9 text-base md:text-sm"
> >
{emailOtpLoading ? ( {emailOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" /> <RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -1016,7 +985,7 @@ export function Profile() {
<Button <Button
onClick={handleTotpSetup} onClick={handleTotpSetup}
disabled={totpLoading} disabled={totpLoading}
className="w-full" className="w-full h-11 md:h-9 text-base md:text-sm"
> >
{totpLoading ? ( {totpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" /> <RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -1055,12 +1024,13 @@ export function Profile() {
<Input <Input
value={otpStatus.totpSecret} value={otpStatus.totpSecret}
readOnly readOnly
className="font-mono text-xs" className="font-mono text-xs h-11 md:h-9"
/> />
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={copySecret} onClick={copySecret}
className="h-11 md:h-9 min-w-[44px]"
> >
{secretCopied ? ( {secretCopied ? (
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
@@ -1079,10 +1049,12 @@ export function Profile() {
value={totpCode} value={totpCode}
onChange={(e) => setTotpCode(e.target.value)} onChange={(e) => setTotpCode(e.target.value)}
maxLength={6} maxLength={6}
className="h-11 md:h-9 text-base md:text-sm"
/> />
<Button <Button
onClick={handleTotpVerify} onClick={handleTotpVerify}
disabled={totpLoading || totpCode.length !== 6} disabled={totpLoading || totpCode.length !== 6}
className="h-11 md:h-9 text-base md:text-sm min-w-[100px]"
> >
{totpLoading ? ( {totpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin" /> <RefreshCw className="h-4 w-4 animate-spin" />
@@ -1099,6 +1071,7 @@ export function Profile() {
variant="destructive" variant="destructive"
onClick={handleTotpDisable} onClick={handleTotpDisable}
disabled={totpLoading} disabled={totpLoading}
className="w-full h-11 md:h-9 text-base md:text-sm"
> >
{totpLoading ? ( {totpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" /> <RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -1112,7 +1085,10 @@ export function Profile() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Danger Zone */}
<Separator className="my-8" />
{/* Danger Zone - Inside Security Tab */}
<Card className="border-destructive"> <Card className="border-destructive">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive"> <CardTitle className="flex items-center gap-2 text-destructive">
@@ -1146,14 +1122,15 @@ export function Profile() {
</Alert> </Alert>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="delete-password">{t.profile.enterPasswordToDelete}</Label> <Label htmlFor="delete-password" className="text-base md:text-sm">{t.profile.enterPasswordToDelete}</Label>
<Input <Input
id="delete-password" id="delete-password"
type="password" type="password"
placeholder="Enter your password" placeholder={t.profile.enterPasswordToDeletePlaceholder}
value={deletePassword} value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)} onChange={(e) => setDeletePassword(e.target.value)}
disabled={deleteLoading} disabled={deleteLoading}
className="h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -1161,6 +1138,7 @@ export function Profile() {
variant="destructive" variant="destructive"
onClick={handleDeleteAccount} onClick={handleDeleteAccount}
disabled={deleteLoading || !deletePassword} disabled={deleteLoading || !deletePassword}
className="h-11 md:h-9 text-base md:text-sm"
> >
{deleteLoading ? ( {deleteLoading ? (
<> <>
@@ -1182,6 +1160,7 @@ export function Profile() {
setDeleteError("") setDeleteError("")
}} }}
disabled={deleteLoading} disabled={deleteLoading}
className="h-11 md:h-9 text-base md:text-sm"
> >
Cancel Cancel
</Button> </Button>
@@ -1191,6 +1170,7 @@ export function Profile() {
<Button <Button
variant="destructive" variant="destructive"
onClick={() => setShowDeleteDialog(true)} onClick={() => setShowDeleteDialog(true)}
className="h-11 md:h-9 text-base md:text-sm"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
{t.profile.deleteAccount} {t.profile.deleteAccount}
@@ -1202,5 +1182,6 @@ export function Profile() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
</div>
) )
} }

View File

@@ -272,11 +272,11 @@ export function Transactions() {
</p> </p>
</div> </div>
<div className="flex gap-2 sm:flex-shrink-0"> <div className="flex gap-2 sm:flex-shrink-0">
<Button variant="outline" onClick={() => setShowFilters(!showFilters)}> <Button variant="outline" onClick={() => setShowFilters(!showFilters)} className="h-11 md:h-9 text-base md:text-sm">
<Filter className="mr-2 h-4 w-4" /> <Filter className="mr-2 h-4 w-4" />
{showFilters ? t.common.hideFilters : t.common.showFilters} {showFilters ? t.common.hideFilters : t.common.showFilters}
</Button> </Button>
<Button onClick={() => setTransactionDialogOpen(true)}> <Button onClick={() => setTransactionDialogOpen(true)} className="h-11 md:h-9 text-base md:text-sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
{t.transactions.addTransaction} {t.transactions.addTransaction}
</Button> </Button>
@@ -333,7 +333,7 @@ export function Transactions() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={clearFilters} onClick={clearFilters}
className="h-8 text-xs" className="h-9 md:h-7 px-3 md:px-2 text-sm"
> >
<X className="h-3 w-3 mr-1" /> <X className="h-3 w-3 mr-1" />
{t.common.clearAll} {t.common.clearAll}
@@ -345,23 +345,23 @@ export function Transactions() {
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
{/* Search */} {/* Search */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.searchMemo}</Label> <Label className="text-base md:text-xs font-medium text-muted-foreground">{t.transactions.filter.searchMemo}</Label>
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-3 md:top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder={t.transactions.filter.searchMemoPlaceholder} placeholder={t.transactions.filter.searchMemoPlaceholder}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9" className="pl-9 h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
</div> </div>
{/* Wallet Filter */} {/* Wallet Filter */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.wallet}</Label> <Label className="text-base md:text-xs font-medium text-muted-foreground">{t.transactions.filter.wallet}</Label>
<Select value={walletFilter} onValueChange={setWalletFilter}> <Select value={walletFilter} onValueChange={setWalletFilter}>
<SelectTrigger className="h-9"> <SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.transactions.filter.walletPlaceholder} /> <SelectValue placeholder={t.transactions.filter.walletPlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -377,9 +377,9 @@ export function Transactions() {
{/* Direction Filter */} {/* Direction Filter */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Direction</Label> <Label className="text-base md:text-xs font-medium text-muted-foreground">Direction</Label>
<Select value={directionFilter} onValueChange={setDirectionFilter}> <Select value={directionFilter} onValueChange={setDirectionFilter}>
<SelectTrigger className="h-9"> <SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.transactions.filter.directionPlaceholder} /> <SelectValue placeholder={t.transactions.filter.directionPlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -394,24 +394,24 @@ export function Transactions() {
{/* Row 2: Amount Range */} {/* Row 2: Amount Range */}
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.minAmount}</Label> <Label className="text-base md:text-xs font-medium text-muted-foreground">{t.transactions.filter.minAmount}</Label>
<Input <Input
type="number" type="number"
placeholder={t.transactions.filter.minAmountPlaceholder} placeholder={t.transactions.filter.minAmountPlaceholder}
value={amountMin} value={amountMin}
onChange={(e) => setAmountMin(e.target.value)} onChange={(e) => setAmountMin(e.target.value)}
className="h-9" className="h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.maxAmount}</Label> <Label className="text-base md:text-xs font-medium text-muted-foreground">{t.transactions.filter.maxAmount}</Label>
<Input <Input
type="number" type="number"
placeholder={t.transactions.filter.maxAmountPlaceholder} placeholder={t.transactions.filter.maxAmountPlaceholder}
value={amountMax} value={amountMax}
onChange={(e) => setAmountMax(e.target.value)} onChange={(e) => setAmountMax(e.target.value)}
className="h-9" className="h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
</div> </div>
@@ -506,11 +506,11 @@ export function Transactions() {
<TableRow> <TableRow>
<TableHead>{t.transactions.tableTheadDate}</TableHead> <TableHead>{t.transactions.tableTheadDate}</TableHead>
<TableHead className="text-nowrap">{t.transactions.tableTheadWallet}</TableHead> <TableHead className="text-nowrap">{t.transactions.tableTheadWallet}</TableHead>
<TableHead className="text-center">{t.transactions.tableTheadDirection}</TableHead> <TableHead className="text-center text-nowrap">{t.transactions.tableTheadDirection}</TableHead>
<TableHead className="text-right">{t.transactions.tableTheadAmount}</TableHead> <TableHead className="text-right text-nowrap">{t.transactions.tableTheadAmount}</TableHead>
<TableHead>{t.transactions.tableTheadCategory}</TableHead> <TableHead className="text-nowrap">{t.transactions.tableTheadCategory}</TableHead>
<TableHead>{t.transactions.tableTheadMemo}</TableHead> <TableHead className="text-nowrap">{t.transactions.tableTheadMemo}</TableHead>
<TableHead className="text-right">{t.transactions.tableTheadActions}</TableHead> <TableHead className="text-right text-nowrap">{t.transactions.tableTheadActions}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -541,7 +541,7 @@ export function Transactions() {
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center text-nowrap">
<Badge <Badge
variant={`outline`} variant={`outline`}
className={transaction.direction === 'in' ? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] stroke-[var(--color-primary)] ring-1 ring-[var(--color-primary)]/75' : 'bg-[var(--color-destructive)]/10 text-[var(--color-destructive)] stroke-[var(--color-destructive)] ring-1 ring-[var(--color-destructive)]/75'} className={transaction.direction === 'in' ? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] stroke-[var(--color-primary)] ring-1 ring-[var(--color-primary)]/75' : 'bg-[var(--color-destructive)]/10 text-[var(--color-destructive)] stroke-[var(--color-destructive)] ring-1 ring-[var(--color-destructive)]/75'}
@@ -552,7 +552,7 @@ export function Transactions() {
<TableCell className="font-mono text-right text-nowrap"> <TableCell className="font-mono text-right text-nowrap">
{formatCurrency(transaction.amount, wallet?.currency || wallet?.unit || 'IDR')} {formatCurrency(transaction.amount, wallet?.currency || wallet?.unit || 'IDR')}
</TableCell> </TableCell>
<TableCell> <TableCell className="text-nowrap">
{transaction.category && ( {transaction.category && (
<Badge variant="outline">{transaction.category}</Badge> <Badge variant="outline">{transaction.category}</Badge>
)} )}
@@ -560,7 +560,7 @@ export function Transactions() {
<TableCell className="max-w-[200px] truncate"> <TableCell className="max-w-[200px] truncate">
{transaction.memo} {transaction.memo}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right text-nowrap">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => handleEditTransaction(transaction)}> <Button variant="ghost" size="sm" onClick={() => handleEditTransaction(transaction)}>
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />

View File

@@ -161,11 +161,11 @@ export function Wallets() {
</p> </p>
</div> </div>
<div className="flex gap-2 sm:flex-shrink-0"> <div className="flex gap-2 sm:flex-shrink-0">
<Button variant="outline" onClick={() => setShowFilters(!showFilters)}> <Button variant="outline" onClick={() => setShowFilters(!showFilters)} className="h-11 md:h-9 text-base md:text-sm">
<Filter className="mr-2 h-4 w-4" /> <Filter className="mr-2 h-4 w-4" />
{showFilters ? t.common.hideFilters : t.common.showFilters} {showFilters ? t.common.hideFilters : t.common.showFilters}
</Button> </Button>
<Button onClick={() => setWalletDialogOpen(true)}> <Button onClick={() => setWalletDialogOpen(true)} className="h-11 md:h-9 text-base md:text-sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
{t.wallets.addWallet} {t.wallets.addWallet}
</Button> </Button>
@@ -223,7 +223,7 @@ export function Wallets() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={clearFilters} onClick={clearFilters}
className="h-7 px-2" className="h-9 md:h-7 px-3 md:px-2 text-sm"
> >
<X className="h-3 w-3 mr-1" /> <X className="h-3 w-3 mr-1" />
{t.common.clearAll} {t.common.clearAll}
@@ -235,23 +235,23 @@ export function Wallets() {
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
{/* Search */} {/* Search */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.common.search}</Label> <Label className="text-base md:text-xs font-medium text-muted-foreground">{t.common.search}</Label>
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-3 md:top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder={t.wallets.searchPlaceholder} placeholder={t.wallets.searchPlaceholder}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9" className="pl-9 h-11 md:h-9 text-base md:text-sm"
/> />
</div> </div>
</div> </div>
{/* Type Filter */} {/* Type Filter */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.wallets.type}</Label> <Label className="text-base md:text-xs font-medium text-muted-foreground">{t.wallets.type}</Label>
<Select value={kindFilter} onValueChange={setKindFilter}> <Select value={kindFilter} onValueChange={setKindFilter}>
<SelectTrigger className="h-9"> <SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.common.all} /> <SelectValue placeholder={t.common.all} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -264,9 +264,9 @@ export function Wallets() {
{/* Currency Filter */} {/* Currency Filter */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.wallets.currency}/{t.wallets.unit}</Label> <Label className="text-base md:text-xs font-medium text-muted-foreground">{t.wallets.currency}/{t.wallets.unit}</Label>
<Select value={currencyFilter} onValueChange={setCurrencyFilter}> <Select value={currencyFilter} onValueChange={setCurrencyFilter}>
<SelectTrigger className="h-9"> <SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.common.all} /> <SelectValue placeholder={t.common.all} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -330,11 +330,11 @@ export function Wallets() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t.wallets.name}</TableHead> <TableHead className="text-nowrap">{t.wallets.name}</TableHead>
<TableHead>{t.wallets.currency}/{t.wallets.unit}</TableHead> <TableHead className="text-center text-nowrap">{t.wallets.currency}/{t.wallets.unit}</TableHead>
<TableHead>{t.wallets.type}</TableHead> <TableHead className="text-center text-nowrap">{t.wallets.type}</TableHead>
<TableHead>{t.common.date}</TableHead> <TableHead className="text-center text-nowrap">{t.common.date}</TableHead>
<TableHead className="text-right">{t.common.actions}</TableHead> <TableHead className="text-right text-nowrap">{t.common.actions}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -351,14 +351,14 @@ export function Wallets() {
filteredWallets.map((wallet) => ( filteredWallets.map((wallet) => (
<TableRow key={wallet.id}> <TableRow key={wallet.id}>
<TableCell className="font-medium text-nowrap">{wallet.name}</TableCell> <TableCell className="font-medium text-nowrap">{wallet.name}</TableCell>
<TableCell> <TableCell className="text-center text-nowrap">
{wallet.kind === 'money' ? ( {wallet.kind === 'money' ? (
<Badge variant="outline" className="text-nowrap">{wallet.currency}</Badge> <Badge variant="outline" className="text-nowrap">{wallet.currency}</Badge>
) : ( ) : (
<Badge variant="outline" className="text-nowrap">{wallet.unit}</Badge> <Badge variant="outline" className="text-nowrap">{wallet.unit}</Badge>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell className="text-center text-nowrap">
<Badge <Badge
variant="outline" variant="outline"
className={`text-nowrap ${wallet.kind === 'money' ? 'bg-[var(--chart-1)]/20 text-[var(--chart-1)] ring-1 ring-[var(--chart-1)]' : 'bg-[var(--chart-2)]/20 text-[var(--chart-2)] ring-1 ring-[var(--chart-2)]'}`} className={`text-nowrap ${wallet.kind === 'money' ? 'bg-[var(--chart-1)]/20 text-[var(--chart-1)] ring-1 ring-[var(--chart-1)]' : 'bg-[var(--chart-2)]/20 text-[var(--chart-2)] ring-1 ring-[var(--chart-2)]'}`}
@@ -366,10 +366,10 @@ export function Wallets() {
{wallet.kind === 'money' ? t.wallets.money : t.wallets.asset} {wallet.kind === 'money' ? t.wallets.money : t.wallets.asset}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell className="text-center text-nowrap">
{new Date(wallet.createdAt).toLocaleDateString()} {new Date(wallet.createdAt).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right text-nowrap">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => handleEditWallet(wallet)}> <Button variant="ghost" size="sm" onClick={() => handleEditWallet(wallet)}>
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />

View File

@@ -34,7 +34,7 @@ function Calendar({
head_row: "flex", head_row: "flex",
head_cell: "rdp-weekday text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", head_cell: "rdp-weekday text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2", row: "flex w-full mt-2",
cell: "rdp-day h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", cell: "rdp-day h-11 w-11 md:h-9 md:w-9 text-center text-base md:text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: "rdp-day_button", day: "rdp-day_button",
day_range_end: "rdp-range_end day-range-end", day_range_end: "rdp-range_end day-range-end",
day_selected: "rdp-selected", day_selected: "rdp-selected",

View File

@@ -0,0 +1,82 @@
"use client"
import * as React from "react"
import { Plus, TrendingUp, Wallet, Receipt } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface FABAction {
icon: React.ReactNode
label: string
onClick: () => void
variant?: "default" | "outline" | "secondary"
}
interface FloatingActionButtonProps {
actions: FABAction[]
className?: string
}
export function FloatingActionButton({ actions, className }: FloatingActionButtonProps) {
const [isOpen, setIsOpen] = React.useState(false)
const toggleMenu = () => setIsOpen(!isOpen)
const handleActionClick = (action: FABAction) => {
action.onClick()
setIsOpen(false)
}
return (
<>
{/* Backdrop Overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black/40 backdrop-blur-xs z-40 animate-in fade-in duration-200"
onClick={() => setIsOpen(false)}
/>
)}
{/* FAB Container */}
<div className={cn("fixed bottom-6 right-6 z-50 flex flex-col items-end gap-3", className)}>
{/* Main FAB Button - Always at bottom */}
<Button
size="lg"
onClick={toggleMenu}
className={cn(
"h-16 w-16 rounded-full shadow-2xl hover:scale-110 transition-all duration-200 order-last",
isOpen && "rotate-45"
)}
>
<Plus className="h-6 w-6" />
</Button>
{/* Action Menu - Above main button */}
{isOpen && (
<div className="flex flex-col gap-3 animate-in fade-in slide-in-from-bottom-2 duration-200">
{actions.map((action, index) => (
<div key={index} className="flex items-center gap-3 justify-end">
{/* Label */}
<span className="bg-background border shadow-lg rounded-lg px-3 py-2 text-sm font-medium whitespace-nowrap">
{action.label}
</span>
{/* Action Button */}
<Button
size="lg"
variant={action.variant || "secondary"}
onClick={() => handleActionClick(action)}
className="h-14 w-14 rounded-full shadow-lg hover:scale-110 transition-transform px-0"
>
{action.icon}
</Button>
</div>
))}
</div>
)}
</div>
</>
)
}
// Export icons for convenience
export { TrendingUp as FABTrendingUpIcon, Wallet as FABWalletIcon, Receipt as FABReceiptIcon }

View File

@@ -63,7 +63,7 @@ function MultiSelect({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={cn("overflow-visible bg-transparent", className)} className={cn("overflow-visible bg-transparent", className)}
> >
<div className="group rounded-md border border-input px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"> <div className="group rounded-md border border-input px-3 py-2 min-h-[44px] md:min-h-[36px] text-base md:text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{selected.map((item) => { {selected.map((item) => {
const option = options.find((opt) => opt.value === item) const option = options.find((opt) => opt.value === item)
@@ -120,7 +120,7 @@ function MultiSelect({
handleSelect(inputValue) handleSelect(inputValue)
setOpen(false) setOpen(false)
}} }}
className="cursor-pointer" className="cursor-pointer min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
> >
Create "{inputValue}" Create "{inputValue}"
</CommandItem> </CommandItem>
@@ -143,7 +143,7 @@ function MultiSelect({
handleSelect(option.value) handleSelect(option.value)
setOpen(false) setOpen(false)
}} }}
className="cursor-pointer" className="cursor-pointer min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
> >
{option.label} {option.label}
</CommandItem> </CommandItem>

View File

@@ -118,6 +118,7 @@ export function MultipleSelector({
handleSetValue(inputValue) handleSetValue(inputValue)
setInputValue("") setInputValue("")
}} }}
className="min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
> >
<Check <Check
className={cn( className={cn(
@@ -140,6 +141,7 @@ export function MultipleSelector({
onSelect={() => { onSelect={() => {
handleSetValue(option.value) handleSetValue(option.value)
}} }}
className="min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
> >
<Check <Check
className={cn( className={cn(

View File

@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-sm py-2.5 md:py-1.5 pl-2 pr-8 text-base md:text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 min-h-[44px] md:min-h-0",
className className
)} )}
{...props} {...props}

View File

@@ -58,7 +58,7 @@ const TableRow = React.forwardRef<
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted h-14 md:h-auto",
className className
)} )}
{...props} {...props}
@@ -73,7 +73,7 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "h-12 md:h-10 px-3 md:px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-base md:text-sm",
className className
)} )}
{...props} {...props}
@@ -88,7 +88,7 @@ const TableCell = React.forwardRef<
<td <td
ref={ref} ref={ref}
className={cn( className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-3 md:p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-base md:text-sm",
className className
)} )}
{...props} {...props}

View File

@@ -41,8 +41,9 @@ export const getCurrencyByCode = (code: string) => {
}; };
export const formatCurrency = (amount: number, currencyCode: string) => { export const formatCurrency = (amount: number, currencyCode: string) => {
const useLanguage = localStorage.getItem('language') || 'en';
const currency = getCurrencyByCode(currencyCode); const currency = getCurrencyByCode(currencyCode);
if (!currency) return `${amount} ${(amount === 1) ? currencyCode : currencyCode + 's'}`; if (!currency) return `${amount} ${(amount === 1) ? currencyCode : currencyCode + (useLanguage == 'en' ? 's' : '')}`;
// For IDR, format without decimals // For IDR, format without decimals
if (currencyCode === 'IDR') { if (currencyCode === 'IDR') {

View File

@@ -419,3 +419,28 @@ body {
.rdp-vhidden { .rdp-vhidden {
display: none; display: none;
} }
@media only screen and (max-width: 48rem) {
[data-radix-popper-content-wrapper] {
width: 90%;
}
[data-radix-popper-content-wrapper] .rdp-months,
[data-radix-popper-content-wrapper] .rdp-months .rdp-month,
[data-radix-popper-content-wrapper] table{
width: 100%;
max-width: unset;
}
[data-radix-popper-content-wrapper] .rdp-dropdown_root{
height: 2.75rem;
}
[data-radix-popper-content-wrapper] table tbody tr {
display: flex;
}
[data-radix-popper-content-wrapper] table *:is(th, td){
flex-grow: 1;
}
[data-radix-popper-content-wrapper] table .rdp-day_button {
height: 2.75rem;
width: 2.75rem;
}
}

View File

@@ -2,18 +2,17 @@ export const en = {
common: { common: {
search: 'Search', search: 'Search',
filter: 'Filter', filter: 'Filter',
clearAll: 'Clear All',
add: 'Add',
edit: 'Edit',
delete: 'Delete',
cancel: 'Cancel', cancel: 'Cancel',
save: 'Save', save: 'Save',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
close: 'Close', close: 'Close',
loading: 'Loading...',
noData: 'No data',
confirm: 'Confirm', confirm: 'Confirm',
loading: 'Loading...',
noData: 'No data available',
error: 'An error occurred',
success: 'Success', success: 'Success',
error: 'Error',
total: 'Total', total: 'Total',
date: 'Date', date: 'Date',
amount: 'Amount', amount: 'Amount',
@@ -29,6 +28,12 @@ export const en = {
hideFilters: 'Hide Filters', hideFilters: 'Hide Filters',
}, },
numberFormat: {
thousand: 'k',
million: 'm',
billion: 'b',
},
nav: { nav: {
overview: 'Overview', overview: 'Overview',
transactions: 'Transactions', transactions: 'Transactions',
@@ -44,6 +49,7 @@ export const en = {
overviewPeriodPlaceholder: 'Select period', overviewPeriodPlaceholder: 'Select period',
customStartDatePlaceholder: 'Pick start date', customStartDatePlaceholder: 'Pick start date',
customEndDatePlaceholder: 'Pick end date', customEndDatePlaceholder: 'Pick end date',
selectDateRange: 'Select date range',
totalBalance: 'Total Balance', totalBalance: 'Total Balance',
totalIncome: 'Total Income', totalIncome: 'Total Income',
totalExpense: 'Total Expense', totalExpense: 'Total Expense',
@@ -200,9 +206,11 @@ export const en = {
expense: 'Expense', expense: 'Expense',
category: 'Category', category: 'Category',
categoryPlaceholder: 'Select or type new category', categoryPlaceholder: 'Select or type new category',
selectCategory: 'Select or type new category',
addCategory: 'Add', addCategory: 'Add',
memo: 'Memo (Optional)', memo: 'Memo (Optional)',
memoPlaceholder: 'Add a note...', memoPlaceholder: 'Add a note...',
addMemo: 'Add a note...',
date: 'Date', date: 'Date',
selectDate: 'Select date', selectDate: 'Select date',
addSuccess: 'Transaction added successfully', addSuccess: 'Transaction added successfully',
@@ -227,16 +235,25 @@ export const en = {
save: 'Save', save: 'Save',
update: 'Update', update: 'Update',
cancel: 'Cancel', cancel: 'Cancel',
nameSaved: 'Name saved successfully',
nameError: 'Name cannot be empty',
nameSuccess: 'Name updated successfully',
nameLoading: 'Updating name...',
nameLoadingError: 'Failed to update name',
email: 'Email', email: 'Email',
emailVerified: 'Email Verified', emailVerified: 'Email Verified',
emailNotVerified: 'Email Not Verified', emailNotVerified: 'Email Not Verified',
emailCannotBeChanged: 'Email cannot be changed', emailCannotBeChanged: 'Email cannot be changed',
avatar: 'Avatar', avatar: 'Avatar',
changeAvatar: 'Change Avatar', changeAvatar: 'Change Avatar',
uploadAvatar: 'Upload Avatar', uploadAvatar: 'Upload Avatar',
avatarSynced: 'Avatar is synced from your Google account', avatarSynced: 'Avatar is synced from your Google account',
clickUploadAvatar: 'Click the upload button to change your avatar', clickUploadAvatar: 'Click the upload button to change your avatar',
uploading: 'Uploading...', uploading: 'Uploading...',
avatarSuccess: 'Avatar updated successfully',
avatarError: 'Failed to update avatar',
security: 'Security', security: 'Security',
password: 'Password', password: 'Password',
@@ -252,6 +269,10 @@ export const en = {
updating: 'Updating...', updating: 'Updating...',
setPassword: 'Set Password', setPassword: 'Set Password',
updatePassword: 'Update Password', updatePassword: 'Update Password',
passwordSetSuccess: 'Password set successfully',
passwordChangeSuccess: 'Password changed successfully',
passwordError: 'Failed to set password',
enterPassword: 'Please enter your password',
twoFactor: 'Two-Factor Authentication', twoFactor: 'Two-Factor Authentication',
twoFactorDesc: 'Add an extra layer of security to your account', twoFactorDesc: 'Add an extra layer of security to your account',
@@ -259,12 +280,20 @@ export const en = {
phoneNumberPlaceholder: '+62812345678', phoneNumberPlaceholder: '+62812345678',
updatePhone: 'Update Phone', updatePhone: 'Update Phone',
phoneNumberDescription: 'Required for WhatsApp OTP verification', phoneNumberDescription: 'Required for WhatsApp OTP verification',
phoneInvalid: 'Invalid phone number',
phoneNotRegistered: 'This number is not registered on WhatsApp. Please try another number.',
phoneSuccess: 'Phone number updated successfully',
phoneError: 'Failed to update phone number',
emailOtp: 'Email OTP', emailOtp: 'Email OTP',
emailOtpDesc: 'Receive verification codes via email', emailOtpDesc: 'Receive verification codes via email',
enableEmailOtp: 'Enable Email OTP', enableEmailOtp: 'Enable Email OTP',
disableEmailOtp: 'Disable Email OTP', disableEmailOtp: 'Disable Email OTP',
checkYourEmailForTheVerificationCode: 'Check your email for the verification code', checkYourEmailForTheVerificationCode: 'Check your email for the verification code',
emailOtpSent: 'OTP code has been sent to your email',
emailOtpEnabled: 'Email OTP enabled successfully',
emailOtpDisabled: 'Email OTP disabled successfully',
emailOtpError: 'Failed to send OTP code',
enable: 'Enable', enable: 'Enable',
disable: 'Disable', disable: 'Disable',
enabled: 'Enabled', enabled: 'Enabled',
@@ -280,6 +309,10 @@ export const en = {
pleaseAddYourPhoneNumberInTheEditProfileTabFirst: 'Please add your phone number in the Edit Profile tab first', pleaseAddYourPhoneNumberInTheEditProfileTabFirst: 'Please add your phone number in the Edit Profile tab first',
checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode: 'Check your WhatsApp for the verification code', checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode: 'Check your WhatsApp for the verification code',
enterVerificationCode: 'Enter 6 digit code', enterVerificationCode: 'Enter 6 digit code',
whatsappOtpSent: 'OTP code has been sent to WhatsApp',
whatsappOtpEnabled: 'WhatsApp OTP enabled successfully',
whatsappOtpDisabled: 'WhatsApp OTP disabled successfully',
whatsappOtpError: 'Failed to send OTP code',
authenticatorApp: 'Authenticator App', authenticatorApp: 'Authenticator App',
authenticatorDesc: 'Use an authenticator app like Google Authenticator', authenticatorDesc: 'Use an authenticator app like Google Authenticator',
@@ -290,6 +323,9 @@ export const en = {
setupSecretKey: 'Secret Key (if you can\'t scan QR code):', setupSecretKey: 'Secret Key (if you can\'t scan QR code):',
enableAuthenticatorApp: 'Enable Authenticator App', enableAuthenticatorApp: 'Enable Authenticator App',
disableAuthenticatorApp: 'Disable Authenticator App', disableAuthenticatorApp: 'Disable Authenticator App',
totpEnabled: 'Authenticator App enabled successfully',
totpDisabled: 'Authenticator App disabled successfully',
totpError: 'Failed to enable Authenticator App',
scanQr: 'Scan QR Code', scanQr: 'Scan QR Code',
scanQrDesc: 'Scan this QR code with your authenticator app', scanQrDesc: 'Scan this QR code with your authenticator app',
manualEntry: 'Or enter this code manually:', manualEntry: 'Or enter this code manually:',
@@ -302,8 +338,11 @@ export const en = {
deletePasswordRequired: 'You must set a password first before you can delete your account. Go to "Set Password" above.', deletePasswordRequired: 'You must set a password first before you can delete your account. Go to "Set Password" above.',
deleteAccountConfirm: 'Are you sure you want to delete your account? All data will be permanently lost.', deleteAccountConfirm: 'Are you sure you want to delete your account? All data will be permanently lost.',
enterPasswordToDelete: 'Enter your password to confirm', enterPasswordToDelete: 'Enter your password to confirm',
enterPasswordToDeletePlaceholder: 'Enter your password',
deleting: 'Deleting...', deleting: 'Deleting...',
yesDeleteMyAccount: 'Yes, Delete My Account', yesDeleteMyAccount: 'Yes, Delete My Account',
deleteSuccess: 'Account deleted successfully',
deleteError: 'Failed to delete account',
}, },
dateRange: { dateRange: {
@@ -313,7 +352,30 @@ export const en = {
lastMonth: 'Last month', lastMonth: 'Last month',
thisYear: 'This year', thisYear: 'This year',
custom: 'Custom', custom: 'Custom',
from: 'From', from: 'Start Date',
to: 'To', to: 'End Date',
},
fab: {
updateAssetPrices: 'Update Asset Prices',
quickTransaction: 'Quick Transaction',
quickWallet: 'Quick Wallet',
},
assetPriceUpdate: {
title: 'Update Asset Prices',
description: 'Update the price per unit for your asset wallets',
noAssets: 'You don\'t have any asset wallets yet. Create an asset wallet first.',
noChanges: 'No price changes detected',
pricePerUnit: 'Price per {unit}',
currentPrice: 'Current price',
lastUpdated: 'Last updated',
justNow: 'Just now',
minutesAgo: '{minutes} minutes ago',
hoursAgo: '{hours} hours ago',
daysAgo: '{days} days ago',
updateAll: 'Update All',
updateSuccess: '{count} asset price(s) updated successfully',
updateError: 'Failed to update asset prices',
}, },
} }

View File

@@ -29,6 +29,12 @@ export const id = {
hideFilters: 'Sembunyikan Filter', hideFilters: 'Sembunyikan Filter',
}, },
numberFormat: {
thousand: 'rb',
million: 'jt',
billion: 'm',
},
nav: { nav: {
overview: 'Ringkasan', overview: 'Ringkasan',
transactions: 'Transaksi', transactions: 'Transaksi',
@@ -42,8 +48,9 @@ export const id = {
description: 'Ringkasan keuangan dan tindakan cepat', description: 'Ringkasan keuangan dan tindakan cepat',
overviewPeriod: 'Periode Ringkasan', overviewPeriod: 'Periode Ringkasan',
overviewPeriodPlaceholder: 'Pilih Periode', overviewPeriodPlaceholder: 'Pilih Periode',
customStartDatePlaceholder: 'Pilih Tanggal Mulai', customStartDatePlaceholder: 'Pilih tanggal mulai',
customEndDatePlaceholder: 'Pilih Tanggal Selesai', customEndDatePlaceholder: 'Pilih tanggal akhir',
selectDateRange: 'Pilih rentang tanggal',
totalBalance: 'Total Saldo', totalBalance: 'Total Saldo',
totalIncome: 'Total Pemasukan', totalIncome: 'Total Pemasukan',
totalExpense: 'Total Pengeluaran', totalExpense: 'Total Pengeluaran',
@@ -200,9 +207,11 @@ export const id = {
expense: 'Pengeluaran', expense: 'Pengeluaran',
category: 'Kategori', category: 'Kategori',
categoryPlaceholder: 'Pilih atau ketik kategori baru', categoryPlaceholder: 'Pilih atau ketik kategori baru',
selectCategory: 'Pilih atau ketik kategori baru',
addCategory: 'Tambah', addCategory: 'Tambah',
memo: 'Catatan (Opsional)', memo: 'Catatan (Opsional)',
memoPlaceholder: 'Tambahkan catatan...', memoPlaceholder: 'Tambahkan catatan...',
addMemo: 'Tambahkan catatan...',
date: 'Tanggal', date: 'Tanggal',
selectDate: 'Pilih tanggal', selectDate: 'Pilih tanggal',
addSuccess: 'Transaksi berhasil ditambahkan', addSuccess: 'Transaksi berhasil ditambahkan',
@@ -227,16 +236,25 @@ export const id = {
save: 'Simpan', save: 'Simpan',
update: 'Update', update: 'Update',
cancel: 'Batal', cancel: 'Batal',
nameSaved: 'Nama berhasil disimpan',
nameError: 'Nama tidak boleh kosong',
nameSuccess: 'Nama berhasil diupdate',
nameLoading: 'Mengupdate nama...',
nameLoadingError: 'Gagal mengupdate nama',
email: 'Email', email: 'Email',
emailVerified: 'Email Terverifikasi', emailVerified: 'Email Terverifikasi',
emailNotVerified: 'Email Belum Terverifikasi', emailNotVerified: 'Email Belum Terverifikasi',
emailCannotBeChanged: 'Email tidak dapat diubah', emailCannotBeChanged: 'Email tidak dapat diubah',
avatar: 'Avatar', avatar: 'Avatar',
changeAvatar: 'Ubah Avatar', changeAvatar: 'Ubah Avatar',
uploadAvatar: 'Unggah Avatar', uploadAvatar: 'Unggah Avatar',
avatarSynced: 'Avatar disinkronkan dari akun Google Anda', avatarSynced: 'Avatar disinkronkan dari akun Google Anda',
clickUploadAvatar: 'Klik tombol unggah untuk mengubah avatar Anda', clickUploadAvatar: 'Klik tombol unggah untuk mengubah avatar Anda',
uploading: 'Mengunggah...', uploading: 'Mengunggah...',
avatarSuccess: 'Avatar berhasil diupdate',
avatarError: 'Gagal mengupdate avatar',
security: 'Keamanan', security: 'Keamanan',
password: 'Password', password: 'Password',
@@ -252,6 +270,10 @@ export const id = {
updating: 'Updating...', updating: 'Updating...',
setPassword: 'Buat Password', setPassword: 'Buat Password',
updatePassword: 'Ubah Password', updatePassword: 'Ubah Password',
passwordSetSuccess: 'Password berhasil diatur',
passwordChangeSuccess: 'Password berhasil diubah',
passwordError: 'Gagal mengatur password',
enterPassword: 'Silakan masukkan password',
twoFactor: 'Autentikasi Dua Faktor', twoFactor: 'Autentikasi Dua Faktor',
twoFactorDesc: 'Tambahkan lapisan keamanan ekstra ke akun Anda', twoFactorDesc: 'Tambahkan lapisan keamanan ekstra ke akun Anda',
@@ -259,12 +281,20 @@ export const id = {
phoneNumberPlaceholder: '+62812345678', phoneNumberPlaceholder: '+62812345678',
updatePhone: 'Update Nomor', updatePhone: 'Update Nomor',
phoneNumberDescription: 'Diperlukan untuk verifikasi WhatsApp OTP', phoneNumberDescription: 'Diperlukan untuk verifikasi WhatsApp OTP',
phoneInvalid: 'Nomor telepon tidak valid',
phoneNotRegistered: 'Nomor ini tidak terdaftar di WhatsApp. Silakan coba nomor lain.',
phoneSuccess: 'Nomor telepon berhasil diupdate',
phoneError: 'Gagal mengupdate nomor telepon',
emailOtp: 'Email OTP', emailOtp: 'Email OTP',
emailOtpDesc: 'Terima kode verifikasi via email', emailOtpDesc: 'Terima kode verifikasi via email',
enableEmailOtp: 'Aktifkan Email OTP', enableEmailOtp: 'Aktifkan Email OTP',
disableEmailOtp: 'Nonaktifkan Email OTP', disableEmailOtp: 'Nonaktifkan Email OTP',
checkYourEmailForTheVerificationCode: 'Cek email Anda untuk kode verifikasi', checkYourEmailForTheVerificationCode: 'Cek email Anda untuk kode verifikasi',
emailOtpSent: 'Kode OTP telah dikirim ke email',
emailOtpEnabled: 'Email OTP berhasil diaktifkan',
emailOtpDisabled: 'Email OTP berhasil dinonaktifkan',
emailOtpError: 'Gagal mengirim kode OTP',
enable: 'Aktifkan', enable: 'Aktifkan',
disable: 'Nonaktifkan', disable: 'Nonaktifkan',
enabled: 'Aktif', enabled: 'Aktif',
@@ -280,6 +310,10 @@ export const id = {
pleaseAddYourPhoneNumberInTheEditProfileTabFirst: 'Tambahkan nomor telepon Anda di tab Edit Profil terlebih dahulu', pleaseAddYourPhoneNumberInTheEditProfileTabFirst: 'Tambahkan nomor telepon Anda di tab Edit Profil terlebih dahulu',
checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode: 'Cek WhatsApp Anda untuk kode verifikasi', checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode: 'Cek WhatsApp Anda untuk kode verifikasi',
enterVerificationCode: 'Masukkan 6 digit kode', enterVerificationCode: 'Masukkan 6 digit kode',
whatsappOtpSent: 'Kode OTP telah dikirim ke WhatsApp',
whatsappOtpEnabled: 'WhatsApp OTP berhasil diaktifkan',
whatsappOtpDisabled: 'WhatsApp OTP berhasil dinonaktifkan',
whatsappOtpError: 'Gagal mengirim kode OTP',
authenticatorApp: 'Authenticator App', authenticatorApp: 'Authenticator App',
authenticatorDesc: 'Gunakan aplikasi authenticator seperti Google Authenticator', authenticatorDesc: 'Gunakan aplikasi authenticator seperti Google Authenticator',
@@ -290,6 +324,9 @@ export const id = {
setupSecretKey: 'Secret Key (jika tidak bisa scan QR code):', setupSecretKey: 'Secret Key (jika tidak bisa scan QR code):',
enableAuthenticatorApp: 'Aktifkan Authenticator App', enableAuthenticatorApp: 'Aktifkan Authenticator App',
disableAuthenticatorApp: 'Nonaktifkan Authenticator App', disableAuthenticatorApp: 'Nonaktifkan Authenticator App',
totpEnabled: 'Authenticator App berhasil diaktifkan',
totpDisabled: 'Authenticator App berhasil dinonaktifkan',
totpError: 'Gagal mengaktifkan Authenticator App',
scanQr: 'Scan QR Code', scanQr: 'Scan QR Code',
scanQrDesc: 'Scan QR code ini dengan aplikasi authenticator Anda', scanQrDesc: 'Scan QR code ini dengan aplikasi authenticator Anda',
manualEntry: 'Atau masukkan kode ini secara manual:', manualEntry: 'Atau masukkan kode ini secara manual:',
@@ -302,8 +339,11 @@ export const id = {
deletePasswordRequired: 'Anda harus membuat password terlebih dahulu sebelum Anda dapat menghapus akun Anda. Buka "Buat Password" di atas.', deletePasswordRequired: 'Anda harus membuat password terlebih dahulu sebelum Anda dapat menghapus akun Anda. Buka "Buat Password" di atas.',
deleteAccountConfirm: 'Apakah Anda yakin ingin menghapus akun Anda? Semua data akan hilang permanen.', deleteAccountConfirm: 'Apakah Anda yakin ingin menghapus akun Anda? Semua data akan hilang permanen.',
enterPasswordToDelete: 'Masukkan password Anda untuk konfirmasi', enterPasswordToDelete: 'Masukkan password Anda untuk konfirmasi',
enterPasswordToDeletePlaceholder: 'Masukkan password Anda',
deleting: 'Menghapus...', deleting: 'Menghapus...',
yesDeleteMyAccount: 'Ya, Hapus Akun Saya', yesDeleteMyAccount: 'Ya, Hapus Akun Saya',
deleteSuccess: 'Akun berhasil dihapus',
deleteError: 'Gagal menghapus akun',
}, },
dateRange: { dateRange: {
@@ -313,7 +353,30 @@ export const id = {
lastMonth: 'Bulan lalu', lastMonth: 'Bulan lalu',
thisYear: 'Tahun ini', thisYear: 'Tahun ini',
custom: 'Kustom', custom: 'Kustom',
from: 'Dari', from: 'Tanggal Mulai',
to: 'Sampai', to: 'Tanggal Akhir',
},
fab: {
updateAssetPrices: 'Perbarui Harga Aset',
quickTransaction: 'Transaksi Cepat',
quickWallet: 'Dompet Cepat',
},
assetPriceUpdate: {
title: 'Perbarui Harga Aset',
description: 'Perbarui harga per unit untuk dompet aset Anda',
noAssets: 'Anda belum memiliki dompet aset. Buat dompet aset terlebih dahulu.',
noChanges: 'Tidak ada perubahan harga yang terdeteksi',
pricePerUnit: 'Harga per {unit}',
currentPrice: 'Harga saat ini',
lastUpdated: 'Terakhir diperbarui',
justNow: 'Baru saja',
minutesAgo: '{minutes} menit yang lalu',
hoursAgo: '{hours} jam yang lalu',
daysAgo: '{days} hari yang lalu',
updateAll: 'Perbarui Semua',
updateSuccess: '{count} harga aset berhasil diperbarui',
updateError: 'Gagal memperbarui harga aset',
}, },
} }