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>;
bulkUpdatePrices(req: RequestWithUser, body: {
updates: Array<{
walletId: string;
pricePerUnit: number;
}>;
}): Promise<{
success: boolean;
updated: number;
wallets: {
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
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<{ delete(req: RequestWithUser, id: string): 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;

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>;
bulkUpdatePrices(userId: string, updates: Array<{
walletId: string;
pricePerUnit: number;
}>): Promise<{
success: boolean;
updated: number;
wallets: {
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
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<{ delete(userId: string, id: string): 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;

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"> <button
<LanguageToggle /> onClick={logout}
<button 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"
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" <LogOut className="h-4 w-4 mr-2" />
> {t.nav.logout}
<LogOut className="h-4 w-4 mr-2" /> </button>
{t.nav.logout}
</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>
<ThemeToggle /> <div className="flex items-center gap-2">
<LanguageToggle />
<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,38 +576,43 @@ export function Overview() {
</SelectContent> </SelectContent>
</Select> </Select>
{/* Custom Date Fields */} {/* Custom Date Range Popover */}
{dateRange === 'custom' && ( {dateRange === 'custom' && (
<div className="flex gap-3"> <Popover>
<DatePicker <PopoverTrigger asChild>
date={customStartDate} <Button variant="outline" className="h-11 md:h-9 text-base md:text-sm">
onDateChange={setCustomStartDate} <Calendar className="mr-2 h-4 w-4" />
placeholder={t.overview.customStartDatePlaceholder} {customStartDate && customEndDate
className="w-50 sm:w-[200px]" ? `${customStartDate.toLocaleDateString()} - ${customEndDate.toLocaleDateString()}`
/> : t.overview.selectDateRange}
<DatePicker </Button>
date={customEndDate} </PopoverTrigger>
onDateChange={setCustomEndDate} <PopoverContent className="w-full md:w-auto p-4" align="end">
placeholder={t.overview.customEndDatePlaceholder} <div className="space-y-3 md:grid md:grid-cols-2 md:gap-3">
className="w-50 sm:w-[200px]" <div>
/> <Label className="text-sm font-medium mb-2 block">{t.dateRange.from}</Label>
</div> <DatePicker
date={customStartDate}
onDateChange={setCustomStartDate}
placeholder={t.overview.customStartDatePlaceholder}
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
date={customEndDate}
onDateChange={setCustomEndDate}
placeholder={t.overview.customEndDatePlaceholder}
className="w-full h-11 md:h-9 text-base md:text-sm"
/>
</div>
</div>
</PopoverContent>
</Popover>
)} )}
</div> </div>
<hr className="my-2 block sm:hidden" />
<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> </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,210 +498,188 @@ 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>
<CardDescription>{t.profile.description}</CardDescription> <CardDescription>{t.profile.description}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Avatar Section */} {/* Avatar Section */}
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="relative"> <div className="relative">
{getAvatarUrl(user?.avatarUrl) ? ( {getAvatarUrl(user?.avatarUrl) ? (
<img <img
src={getAvatarUrl(user?.avatarUrl)!} src={getAvatarUrl(user?.avatarUrl)!}
alt={user?.name || user?.email || 'User'} alt={user?.name || user?.email || 'User'}
className="h-20 w-20 rounded-full object-cover" className="h-20 w-20 rounded-full object-cover"
/>
) : (
<div className="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-10 w-10" />
</div>
)}
{!hasGoogleAuth && (
<label
htmlFor="avatar-upload"
className="absolute bottom-0 right-0 h-7 w-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center cursor-pointer hover:bg-primary/90 transition-colors"
>
{avatarUploading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
<input
id="avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
disabled={avatarUploading}
/>
</label>
)}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">{user?.name || t.profile.name}</h3>
<p className="text-sm text-muted-foreground mb-2">{user?.email}</p>
{hasGoogleAuth ? (
<p className="text-xs text-muted-foreground">
{t.profile.avatarSynced}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t.profile.clickUploadAvatar}
</p>
)}
{avatarError && (
<p className="text-xs text-destructive mt-1">{avatarError}</p>
)}
</div>
</div>
<Separator />
{/* Name Field */}
<div className="space-y-2">
<Label htmlFor="name">{t.profile.name}</Label>
{hasGoogleAuth ? (
<>
<Input
id="name"
type="text"
value={user?.name || ""}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
{t.profile.nameSynced}
</p>
</>
) : (
<>
<div className="flex gap-2">
<Input
id="name"
type="text"
value={isEditingName ? editedName : (user?.name || "")}
onChange={(e) => setEditedName(e.target.value)}
disabled={!isEditingName || nameLoading}
className={!isEditingName ? "bg-muted" : ""}
/>
{isEditingName ? (
<>
<Button
onClick={handleUpdateName}
disabled={nameLoading}
size="sm"
>
{nameLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.save}
</Button>
<Button
variant="outline"
onClick={() => {
setIsEditingName(false)
setEditedName(user?.name || "")
setNameError("")
}}
disabled={nameLoading}
size="sm"
>
{t.profile.cancel}
</Button>
</>
) : (
<Button
variant="outline"
onClick={() => setIsEditingName(true)}
size="sm"
>
{t.profile.edit}
</Button>
)}
</div>
{nameError && (
<p className="text-xs text-destructive">{nameError}</p>
)}
{nameSuccess && (
<p className="text-xs text-green-600">{nameSuccess}</p>
)}
</>
)}
</div>
{/* Email Field */}
<div className="space-y-2">
<Label htmlFor="email">{t.profile.email}</Label>
<Input
id="email"
type="email"
value={user?.email || ""}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
{t.profile.emailCannotBeChanged}
</p>
</div>
{/* Phone Field */}
<div className="space-y-2">
<Label htmlFor="phone">{t.profile.phoneNumber}</Label>
<div className="flex gap-2">
<Input
id="phone"
type="tel"
placeholder="+1234567890"
value={phone}
onChange={(e) => setPhone(e.target.value)}
disabled={phoneLoading}
/> />
<Button ) : (
onClick={handleUpdatePhone} <div className="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center">
disabled={phoneLoading || !phone} <User className="h-10 w-10" />
</div>
)}
{!hasGoogleAuth && (
<label
htmlFor="avatar-upload"
className="absolute bottom-0 right-0 h-7 w-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center cursor-pointer hover:bg-primary/90 transition-colors"
> >
{phoneLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.update} {avatarUploading ? (
</Button> <RefreshCw className="h-4 w-4 animate-spin" />
</div> ) : (
{phoneError && ( <Upload className="h-4 w-4" />
<Alert variant="destructive"> )}
<AlertTriangle className="h-4 w-4" /> <input
<AlertDescription>{phoneError}</AlertDescription> id="avatar-upload"
</Alert> type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
disabled={avatarUploading}
/>
</label>
)} )}
{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> <div className="flex-1">
</Card> <h3 className="text-lg font-semibold">{user?.name || t.profile.name}</h3>
</TabsContent> <p className="text-sm text-muted-foreground mb-2">{user?.email}</p>
{hasGoogleAuth ? (
<p className="text-xs text-muted-foreground">
{t.profile.avatarSynced}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t.profile.clickUploadAvatar}
</p>
)}
{avatarError && (
<p className="text-xs text-destructive mt-1">{avatarError}</p>
)}
</div>
</div>
<Separator />
{/* Name Field */}
<div className="space-y-3 md:space-y-2">
<Label htmlFor="name" className="text-base md:text-sm">{t.profile.name}</Label>
{!isEditingName ? (
<>
<Input
id="name"
type="text"
value={user?.name || ''}
disabled
className="bg-muted h-11 md:h-9 text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">
{t.profile.nameSynced}
</p>
</>
) : (
<>
<div className="flex gap-2">
<Input
id="name-edit"
type="text"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
disabled={nameLoading}
className="h-11 md:h-9 text-base md:text-sm"
/>
<Button
type="button"
onClick={handleUpdateName}
disabled={nameLoading}
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}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setIsEditingName(false)
setEditedName(user?.name || "")
setNameError("")
}}
disabled={nameLoading}
className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm min-w-[100px]"
>
{t.profile.cancel}
</Button>
</div>
{nameError && (
<p className="text-xs text-destructive">{nameError}</p>
)}
</>
)}
</div>
{/* Email Field */}
<div className="space-y-3 md:space-y-2">
<Label htmlFor="email" className="text-base md:text-sm">{t.profile.email}</Label>
<Input
id="email"
type="email"
value={user?.email || ''}
disabled
className="bg-muted h-11 md:h-9 text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">
{t.profile.emailCannotBeChanged}
</p>
</div>
{/* Phone Number Field */}
<div className="space-y-3 md:space-y-2">
<Label htmlFor="phone" className="text-base md:text-sm">{t.profile.phoneNumber}</Label>
<div className="flex gap-2">
<Input
id="phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder={t.profile.phoneNumberPlaceholder}
disabled={phoneLoading}
className="h-11 md:h-9 text-base md:text-sm"
/>
<Button
type="button"
onClick={handleUpdatePhone}
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}
</Button>
</div>
{phoneError && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{phoneError}</AlertDescription>
</Alert>
)}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Security Tab */} {/* Security Tab */}
<TabsContent value="security" className="space-y-6"> <TabsContent value="security" className="space-y-6">
@@ -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',
@@ -28,6 +27,12 @@ export const en = {
showFilters: 'Show Filters', showFilters: 'Show Filters',
hideFilters: 'Hide Filters', hideFilters: 'Hide Filters',
}, },
numberFormat: {
thousand: 'k',
million: 'm',
billion: 'b',
},
nav: { nav: {
overview: 'Overview', overview: 'Overview',
@@ -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

@@ -28,6 +28,12 @@ export const id = {
showFilters: 'Tampilkan Filter', showFilters: 'Tampilkan Filter',
hideFilters: 'Sembunyikan Filter', hideFilters: 'Sembunyikan Filter',
}, },
numberFormat: {
thousand: 'rb',
million: 'jt',
billion: 'm',
},
nav: { nav: {
overview: 'Ringkasan', overview: 'Ringkasan',
@@ -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',
}, },
} }