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) {
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 {
success: true,
isRegistered: true,
message: 'Number is valid (dev mode)',
success: false,
isRegistered: false,
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);
list(req: RequestWithUser): import("@prisma/client").Prisma.PrismaPromise<{
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
name: string;
userId: string;
kind: string;
name: string;
currency: string | null;
unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null;
@@ -23,15 +23,15 @@ export declare class WalletsController {
deletedAt: Date | null;
}[]>;
getAllTransactions(req: RequestWithUser): Promise<{
category: string | null;
id: string;
createdAt: Date;
userId: string;
createdAt: Date;
walletId: string;
date: Date;
amount: import("@prisma/client/runtime/library").Decimal;
direction: string;
date: Date;
category: string | null;
memo: string | null;
walletId: string;
recurrenceId: string | null;
}[]>;
create(req: RequestWithUser, body: {
@@ -43,11 +43,11 @@ export declare class WalletsController {
pricePerUnit?: number;
}): import("@prisma/client").Prisma.Prisma__WalletClient<{
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
name: string;
userId: string;
kind: string;
name: string;
currency: string | null;
unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null;
@@ -65,24 +65,48 @@ export declare class WalletsController {
pricePerUnit?: number;
}): import("@prisma/client").Prisma.Prisma__WalletClient<{
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
name: string;
userId: string;
kind: string;
name: string;
currency: string | null;
unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null;
pricePerUnit: import("@prisma/client/runtime/library").Decimal | null;
deletedAt: Date | null;
}, 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<{
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
name: string;
userId: string;
kind: string;
name: string;
currency: string | null;
unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null;

View File

@@ -39,6 +39,12 @@ let WalletsController = class WalletsController {
update(req, 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) {
return this.wallets.delete(req.user.userId, id);
}
@@ -75,6 +81,14 @@ __decorate([
__metadata("design:paramtypes", [Object, String, Object]),
__metadata("design:returntype", void 0)
], 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([
(0, common_1.Delete)(':id'),
__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);
list(userId: string): import("@prisma/client").Prisma.PrismaPromise<{
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
name: string;
userId: string;
kind: string;
name: string;
currency: string | null;
unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null;
@@ -24,11 +24,11 @@ export declare class WalletsService {
pricePerUnit?: number;
}): import("@prisma/client").Prisma.Prisma__WalletClient<{
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
name: string;
userId: string;
kind: string;
name: string;
currency: string | null;
unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null;
@@ -44,24 +44,44 @@ export declare class WalletsService {
pricePerUnit?: number;
}): import("@prisma/client").Prisma.Prisma__WalletClient<{
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
name: string;
userId: string;
kind: string;
name: string;
currency: string | null;
unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null;
pricePerUnit: import("@prisma/client/runtime/library").Decimal | null;
deletedAt: Date | null;
}, 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<{
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
name: string;
userId: string;
kind: string;
name: string;
currency: string | null;
unit: string | null;
initialAmount: import("@prisma/client/runtime/library").Decimal | null;

View File

@@ -67,6 +67,17 @@ let WalletsService = class WalletsService {
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) {
return this.prisma.wallet.update({
where: { id, userId },

View File

@@ -1 +1 @@
{"version":3,"file":"wallets.service.js","sourceRoot":"","sources":["../../src/wallets/wallets.service.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"wallets.service.js","sourceRoot":"","sources":["../../src/wallets/wallets.service.ts"],"names":[],"mappings":"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"}

View File

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

View File

@@ -4,6 +4,7 @@ import {
Get,
Post,
Put,
Patch,
Delete,
Param,
UseGuards,
@@ -73,6 +74,23 @@ export class WalletsController {
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(@Req() req: RequestWithUser, @Param('id') id: string) {
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) {
// Soft delete by setting deletedAt
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 { DashboardLayout } from "./layout/DashboardLayout"
import { Overview } from "./pages/Overview"
@@ -8,9 +9,28 @@ import { Profile } from "./pages/Profile"
export function Dashboard() {
const location = useLocation()
const navigate = useNavigate()
const [fabWalletDialogOpen, setFabWalletDialogOpen] = useState(false)
const [fabTransactionDialogOpen, setFabTransactionDialogOpen] = useState(false)
const handleOpenWalletDialog = useCallback(() => {
setFabWalletDialogOpen(true)
}, [])
const handleOpenTransactionDialog = useCallback(() => {
setFabTransactionDialogOpen(true)
}, [])
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>
<Route path="/" element={<Overview />} />
<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>
</ResponsiveDialogHeader>
<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">
<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}>
<SelectTrigger>
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.transactionDialog.selectWallet} />
</SelectTrigger>
<SelectContent>
@@ -250,22 +250,22 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
<div className="grid grid-cols-2 gap-4">
<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
id="amount"
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder={t.transactionDialog.amountPlaceholder}
required
placeholder="0"
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
<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)}>
<SelectTrigger>
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -277,31 +277,34 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
</div>
<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
date={selectedDate}
onDateChange={(date) => date && setSelectedDate(date)}
placeholder={t.transactionDialog.selectDate}
className="h-11 md:h-9 text-base md:text-sm w-full"
/>
</div>
<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
options={categoryOptions}
selected={categories}
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 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
id="memo"
value={memo}
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>
@@ -312,10 +315,10 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
)}
</div>
<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}
</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}
</Button>
</ResponsiveDialogFooter>

View File

@@ -149,22 +149,23 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<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">
<Label htmlFor="name">{t.walletDialog.name}</Label>
<Label htmlFor="name" className="text-base md:text-sm">{t.walletDialog.name}</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.walletDialog.namePlaceholder}
className="h-11 md:h-9 text-base md:text-sm"
required
/>
</div>
<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)}>
<SelectTrigger>
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -176,14 +177,14 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
{kind === "money" ? (
<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}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={currencyOpen}
className="w-full justify-between"
className="w-full justify-between h-11 md:h-9 text-base md:text-sm"
>
{currency
? 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)
setCurrencyOpen(false)
}}
className="min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
>
<Check
className={cn(
@@ -224,37 +226,40 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
) : (
<>
<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
id="unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder={t.walletDialog.unitPlaceholder}
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
<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
id="pricePerUnit"
type="number"
step="0.01"
value={pricePerUnit}
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 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
id="initialAmount"
type="number"
step="0.01"
value={initialAmount}
onChange={(e) => setInitialAmount(e.target.value)}
placeholder={t.walletDialog.initialAmountPlaceholder}
placeholder="0"
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
@@ -265,10 +270,10 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
)}
</div>
<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}
</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}
</Button>
</ResponsiveDialogFooter>

View File

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

View File

@@ -1,15 +1,67 @@
import { useState } from "react"
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "./AppSidebar"
import { ThemeToggle } from "@/components/ThemeToggle"
import { LanguageToggle } from "@/components/LanguageToggle"
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 {
children: React.ReactNode
currentPage: string
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 (
<SidebarProvider>
<AppSidebar currentPage={currentPage} onNavigate={onNavigate} />
@@ -27,7 +79,10 @@ export function DashboardLayout({ children, currentPage, onNavigate }: Dashboard
year: 'numeric',
})}
</span>
<ThemeToggle />
<div className="flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
</header>
<div className="flex-1 overflow-auto">
<div className="container mx-auto max-w-7xl p-4">
@@ -35,6 +90,25 @@ export function DashboardLayout({ children, currentPage, onNavigate }: Dashboard
</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>
</SidebarProvider>
)

View File

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

View File

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

View File

@@ -272,11 +272,11 @@ export function Transactions() {
</p>
</div>
<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" />
{showFilters ? t.common.hideFilters : t.common.showFilters}
</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" />
{t.transactions.addTransaction}
</Button>
@@ -333,7 +333,7 @@ export function Transactions() {
variant="ghost"
size="sm"
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" />
{t.common.clearAll}
@@ -345,23 +345,23 @@ export function Transactions() {
<div className="grid gap-3 md:grid-cols-3">
{/* Search */}
<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">
<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
placeholder={t.transactions.filter.searchMemoPlaceholder}
value={searchTerm}
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>
{/* Wallet Filter */}
<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}>
<SelectTrigger className="h-9">
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.transactions.filter.walletPlaceholder} />
</SelectTrigger>
<SelectContent>
@@ -377,9 +377,9 @@ export function Transactions() {
{/* Direction Filter */}
<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}>
<SelectTrigger className="h-9">
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.transactions.filter.directionPlaceholder} />
</SelectTrigger>
<SelectContent>
@@ -394,24 +394,24 @@ export function Transactions() {
{/* Row 2: Amount Range */}
<div className="grid gap-3 md:grid-cols-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
type="number"
placeholder={t.transactions.filter.minAmountPlaceholder}
value={amountMin}
onChange={(e) => setAmountMin(e.target.value)}
className="h-9"
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
<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
type="number"
placeholder={t.transactions.filter.maxAmountPlaceholder}
value={amountMax}
onChange={(e) => setAmountMax(e.target.value)}
className="h-9"
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
</div>
@@ -506,11 +506,11 @@ export function Transactions() {
<TableRow>
<TableHead>{t.transactions.tableTheadDate}</TableHead>
<TableHead className="text-nowrap">{t.transactions.tableTheadWallet}</TableHead>
<TableHead className="text-center">{t.transactions.tableTheadDirection}</TableHead>
<TableHead className="text-right">{t.transactions.tableTheadAmount}</TableHead>
<TableHead>{t.transactions.tableTheadCategory}</TableHead>
<TableHead>{t.transactions.tableTheadMemo}</TableHead>
<TableHead className="text-right">{t.transactions.tableTheadActions}</TableHead>
<TableHead className="text-center text-nowrap">{t.transactions.tableTheadDirection}</TableHead>
<TableHead className="text-right text-nowrap">{t.transactions.tableTheadAmount}</TableHead>
<TableHead className="text-nowrap">{t.transactions.tableTheadCategory}</TableHead>
<TableHead className="text-nowrap">{t.transactions.tableTheadMemo}</TableHead>
<TableHead className="text-right text-nowrap">{t.transactions.tableTheadActions}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -541,7 +541,7 @@ export function Transactions() {
</Badge>
</div>
</TableCell>
<TableCell className="text-center">
<TableCell className="text-center text-nowrap">
<Badge
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'}
@@ -552,7 +552,7 @@ export function Transactions() {
<TableCell className="font-mono text-right text-nowrap">
{formatCurrency(transaction.amount, wallet?.currency || wallet?.unit || 'IDR')}
</TableCell>
<TableCell>
<TableCell className="text-nowrap">
{transaction.category && (
<Badge variant="outline">{transaction.category}</Badge>
)}
@@ -560,7 +560,7 @@ export function Transactions() {
<TableCell className="max-w-[200px] truncate">
{transaction.memo}
</TableCell>
<TableCell className="text-right">
<TableCell className="text-right text-nowrap">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => handleEditTransaction(transaction)}>
<Edit className="h-4 w-4" />

View File

@@ -161,11 +161,11 @@ export function Wallets() {
</p>
</div>
<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" />
{showFilters ? t.common.hideFilters : t.common.showFilters}
</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" />
{t.wallets.addWallet}
</Button>
@@ -223,7 +223,7 @@ export function Wallets() {
variant="ghost"
size="sm"
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" />
{t.common.clearAll}
@@ -235,23 +235,23 @@ export function Wallets() {
<div className="grid gap-3 md:grid-cols-3">
{/* Search */}
<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">
<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
placeholder={t.wallets.searchPlaceholder}
value={searchTerm}
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>
{/* Type Filter */}
<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}>
<SelectTrigger className="h-9">
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.common.all} />
</SelectTrigger>
<SelectContent>
@@ -264,9 +264,9 @@ export function Wallets() {
{/* Currency Filter */}
<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}>
<SelectTrigger className="h-9">
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.common.all} />
</SelectTrigger>
<SelectContent>
@@ -330,11 +330,11 @@ export function Wallets() {
<Table>
<TableHeader>
<TableRow>
<TableHead>{t.wallets.name}</TableHead>
<TableHead>{t.wallets.currency}/{t.wallets.unit}</TableHead>
<TableHead>{t.wallets.type}</TableHead>
<TableHead>{t.common.date}</TableHead>
<TableHead className="text-right">{t.common.actions}</TableHead>
<TableHead className="text-nowrap">{t.wallets.name}</TableHead>
<TableHead className="text-center text-nowrap">{t.wallets.currency}/{t.wallets.unit}</TableHead>
<TableHead className="text-center text-nowrap">{t.wallets.type}</TableHead>
<TableHead className="text-center text-nowrap">{t.common.date}</TableHead>
<TableHead className="text-right text-nowrap">{t.common.actions}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -351,14 +351,14 @@ export function Wallets() {
filteredWallets.map((wallet) => (
<TableRow key={wallet.id}>
<TableCell className="font-medium text-nowrap">{wallet.name}</TableCell>
<TableCell>
<TableCell className="text-center text-nowrap">
{wallet.kind === 'money' ? (
<Badge variant="outline" className="text-nowrap">{wallet.currency}</Badge>
) : (
<Badge variant="outline" className="text-nowrap">{wallet.unit}</Badge>
)}
</TableCell>
<TableCell>
<TableCell className="text-center text-nowrap">
<Badge
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)]'}`}
@@ -366,10 +366,10 @@ export function Wallets() {
{wallet.kind === 'money' ? t.wallets.money : t.wallets.asset}
</Badge>
</TableCell>
<TableCell>
<TableCell className="text-center text-nowrap">
{new Date(wallet.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<TableCell className="text-right text-nowrap">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => handleEditWallet(wallet)}>
<Edit className="h-4 w-4" />

View File

@@ -34,7 +34,7 @@ function Calendar({
head_row: "flex",
head_cell: "rdp-weekday text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
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_range_end: "rdp-range_end day-range-end",
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}
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">
{selected.map((item) => {
const option = options.find((opt) => opt.value === item)
@@ -120,7 +120,7 @@ function MultiSelect({
handleSelect(inputValue)
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}"
</CommandItem>
@@ -143,7 +143,7 @@ function MultiSelect({
handleSelect(option.value)
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}
</CommandItem>

View File

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

View File

@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
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
)}
{...props}

View File

@@ -58,7 +58,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
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
)}
{...props}
@@ -73,7 +73,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
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
)}
{...props}
@@ -88,7 +88,7 @@ const TableCell = React.forwardRef<
<td
ref={ref}
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
)}
{...props}

View File

@@ -41,8 +41,9 @@ export const getCurrencyByCode = (code: string) => {
};
export const formatCurrency = (amount: number, currencyCode: string) => {
const useLanguage = localStorage.getItem('language') || 'en';
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
if (currencyCode === 'IDR') {

View File

@@ -419,3 +419,28 @@ body {
.rdp-vhidden {
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: {
search: 'Search',
filter: 'Filter',
clearAll: 'Clear All',
add: 'Add',
edit: 'Edit',
delete: 'Delete',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
close: 'Close',
loading: 'Loading...',
noData: 'No data',
confirm: 'Confirm',
loading: 'Loading...',
noData: 'No data available',
error: 'An error occurred',
success: 'Success',
error: 'Error',
total: 'Total',
date: 'Date',
amount: 'Amount',
@@ -28,6 +27,12 @@ export const en = {
showFilters: 'Show Filters',
hideFilters: 'Hide Filters',
},
numberFormat: {
thousand: 'k',
million: 'm',
billion: 'b',
},
nav: {
overview: 'Overview',
@@ -44,6 +49,7 @@ export const en = {
overviewPeriodPlaceholder: 'Select period',
customStartDatePlaceholder: 'Pick start date',
customEndDatePlaceholder: 'Pick end date',
selectDateRange: 'Select date range',
totalBalance: 'Total Balance',
totalIncome: 'Total Income',
totalExpense: 'Total Expense',
@@ -200,9 +206,11 @@ export const en = {
expense: 'Expense',
category: 'Category',
categoryPlaceholder: 'Select or type new category',
selectCategory: 'Select or type new category',
addCategory: 'Add',
memo: 'Memo (Optional)',
memoPlaceholder: 'Add a note...',
addMemo: 'Add a note...',
date: 'Date',
selectDate: 'Select date',
addSuccess: 'Transaction added successfully',
@@ -227,16 +235,25 @@ export const en = {
save: 'Save',
update: 'Update',
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',
emailVerified: 'Email Verified',
emailNotVerified: 'Email Not Verified',
emailCannotBeChanged: 'Email cannot be changed',
avatar: 'Avatar',
changeAvatar: 'Change Avatar',
uploadAvatar: 'Upload Avatar',
avatarSynced: 'Avatar is synced from your Google account',
clickUploadAvatar: 'Click the upload button to change your avatar',
uploading: 'Uploading...',
avatarSuccess: 'Avatar updated successfully',
avatarError: 'Failed to update avatar',
security: 'Security',
password: 'Password',
@@ -252,6 +269,10 @@ export const en = {
updating: 'Updating...',
setPassword: 'Set 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',
twoFactorDesc: 'Add an extra layer of security to your account',
@@ -259,12 +280,20 @@ export const en = {
phoneNumberPlaceholder: '+62812345678',
updatePhone: 'Update Phone',
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',
emailOtpDesc: 'Receive verification codes via email',
enableEmailOtp: 'Enable Email OTP',
disableEmailOtp: 'Disable Email OTP',
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',
disable: 'Disable',
enabled: 'Enabled',
@@ -280,6 +309,10 @@ export const en = {
pleaseAddYourPhoneNumberInTheEditProfileTabFirst: 'Please add your phone number in the Edit Profile tab first',
checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode: 'Check your WhatsApp for the verification 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',
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):',
enableAuthenticatorApp: 'Enable 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',
scanQrDesc: 'Scan this QR code with your authenticator app',
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.',
deleteAccountConfirm: 'Are you sure you want to delete your account? All data will be permanently lost.',
enterPasswordToDelete: 'Enter your password to confirm',
enterPasswordToDeletePlaceholder: 'Enter your password',
deleting: 'Deleting...',
yesDeleteMyAccount: 'Yes, Delete My Account',
deleteSuccess: 'Account deleted successfully',
deleteError: 'Failed to delete account',
},
dateRange: {
@@ -313,7 +352,30 @@ export const en = {
lastMonth: 'Last month',
thisYear: 'This year',
custom: 'Custom',
from: 'From',
to: 'To',
from: 'Start Date',
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',
hideFilters: 'Sembunyikan Filter',
},
numberFormat: {
thousand: 'rb',
million: 'jt',
billion: 'm',
},
nav: {
overview: 'Ringkasan',
@@ -42,8 +48,9 @@ export const id = {
description: 'Ringkasan keuangan dan tindakan cepat',
overviewPeriod: 'Periode Ringkasan',
overviewPeriodPlaceholder: 'Pilih Periode',
customStartDatePlaceholder: 'Pilih Tanggal Mulai',
customEndDatePlaceholder: 'Pilih Tanggal Selesai',
customStartDatePlaceholder: 'Pilih tanggal mulai',
customEndDatePlaceholder: 'Pilih tanggal akhir',
selectDateRange: 'Pilih rentang tanggal',
totalBalance: 'Total Saldo',
totalIncome: 'Total Pemasukan',
totalExpense: 'Total Pengeluaran',
@@ -200,9 +207,11 @@ export const id = {
expense: 'Pengeluaran',
category: 'Kategori',
categoryPlaceholder: 'Pilih atau ketik kategori baru',
selectCategory: 'Pilih atau ketik kategori baru',
addCategory: 'Tambah',
memo: 'Catatan (Opsional)',
memoPlaceholder: 'Tambahkan catatan...',
addMemo: 'Tambahkan catatan...',
date: 'Tanggal',
selectDate: 'Pilih tanggal',
addSuccess: 'Transaksi berhasil ditambahkan',
@@ -227,16 +236,25 @@ export const id = {
save: 'Simpan',
update: 'Update',
cancel: 'Batal',
nameSaved: 'Nama berhasil disimpan',
nameError: 'Nama tidak boleh kosong',
nameSuccess: 'Nama berhasil diupdate',
nameLoading: 'Mengupdate nama...',
nameLoadingError: 'Gagal mengupdate nama',
email: 'Email',
emailVerified: 'Email Terverifikasi',
emailNotVerified: 'Email Belum Terverifikasi',
emailCannotBeChanged: 'Email tidak dapat diubah',
avatar: 'Avatar',
changeAvatar: 'Ubah Avatar',
uploadAvatar: 'Unggah Avatar',
avatarSynced: 'Avatar disinkronkan dari akun Google Anda',
clickUploadAvatar: 'Klik tombol unggah untuk mengubah avatar Anda',
uploading: 'Mengunggah...',
avatarSuccess: 'Avatar berhasil diupdate',
avatarError: 'Gagal mengupdate avatar',
security: 'Keamanan',
password: 'Password',
@@ -252,6 +270,10 @@ export const id = {
updating: 'Updating...',
setPassword: 'Buat Password',
updatePassword: 'Ubah Password',
passwordSetSuccess: 'Password berhasil diatur',
passwordChangeSuccess: 'Password berhasil diubah',
passwordError: 'Gagal mengatur password',
enterPassword: 'Silakan masukkan password',
twoFactor: 'Autentikasi Dua Faktor',
twoFactorDesc: 'Tambahkan lapisan keamanan ekstra ke akun Anda',
@@ -259,12 +281,20 @@ export const id = {
phoneNumberPlaceholder: '+62812345678',
updatePhone: 'Update Nomor',
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',
emailOtpDesc: 'Terima kode verifikasi via email',
enableEmailOtp: 'Aktifkan Email OTP',
disableEmailOtp: 'Nonaktifkan Email OTP',
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',
disable: 'Nonaktifkan',
enabled: 'Aktif',
@@ -280,6 +310,10 @@ export const id = {
pleaseAddYourPhoneNumberInTheEditProfileTabFirst: 'Tambahkan nomor telepon Anda di tab Edit Profil terlebih dahulu',
checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode: 'Cek WhatsApp Anda untuk kode verifikasi',
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',
authenticatorDesc: 'Gunakan aplikasi authenticator seperti Google Authenticator',
@@ -290,6 +324,9 @@ export const id = {
setupSecretKey: 'Secret Key (jika tidak bisa scan QR code):',
enableAuthenticatorApp: 'Aktifkan 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',
scanQrDesc: 'Scan QR code ini dengan aplikasi authenticator Anda',
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.',
deleteAccountConfirm: 'Apakah Anda yakin ingin menghapus akun Anda? Semua data akan hilang permanen.',
enterPasswordToDelete: 'Masukkan password Anda untuk konfirmasi',
enterPasswordToDeletePlaceholder: 'Masukkan password Anda',
deleting: 'Menghapus...',
yesDeleteMyAccount: 'Ya, Hapus Akun Saya',
deleteSuccess: 'Akun berhasil dihapus',
deleteError: 'Gagal menghapus akun',
},
dateRange: {
@@ -313,7 +353,30 @@ export const id = {
lastMonth: 'Bulan lalu',
thisYear: 'Tahun ini',
custom: 'Kustom',
from: 'Dari',
to: 'Sampai',
from: 'Tanggal Mulai',
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',
},
}