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:
203
PROJECT_STANDARDS.md
Normal file
203
PROJECT_STANDARDS.md
Normal 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
|
||||
8
apps/api/dist/otp/otp.service.js
vendored
8
apps/api/dist/otp/otp.service.js
vendored
@@ -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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
2
apps/api/dist/otp/otp.service.js.map
vendored
2
apps/api/dist/otp/otp.service.js.map
vendored
File diff suppressed because one or more lines are too long
2
apps/api/dist/tsconfig.build.tsbuildinfo
vendored
2
apps/api/dist/tsconfig.build.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
48
apps/api/dist/wallets/wallets.controller.d.ts
vendored
48
apps/api/dist/wallets/wallets.controller.d.ts
vendored
@@ -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;
|
||||
|
||||
14
apps/api/dist/wallets/wallets.controller.js
vendored
14
apps/api/dist/wallets/wallets.controller.js
vendored
@@ -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)()),
|
||||
|
||||
@@ -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"}
|
||||
36
apps/api/dist/wallets/wallets.service.d.ts
vendored
36
apps/api/dist/wallets/wallets.service.d.ts
vendored
@@ -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;
|
||||
|
||||
11
apps/api/dist/wallets/wallets.service.js
vendored
11
apps/api/dist/wallets/wallets.service.js
vendored
@@ -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 },
|
||||
|
||||
2
apps/api/dist/wallets/wallets.service.js.map
vendored
2
apps/api/dist/wallets/wallets.service.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"wallets.service.js","sourceRoot":"","sources":["../../src/wallets/wallets.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA4C;AAC5C,6DAAyD;AAGlD,IAAM,cAAc,GAApB,MAAM,cAAc;IACL;IAApB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,IAAI,CAAC,MAAc;QACjB,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC;YACjC,KAAK,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE;YAClC,OAAO,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE;SAC9B,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CACJ,MAAc,EACd,KAOC;QAED,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,OAAO,CAAC;QACnC,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,IAAI,EAAE;gBACJ,MAAM;gBACN,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,IAAI;gBACJ,QAAQ,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI;gBAC7D,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI;gBACpD,aAAa,EAAE,KAAK,CAAC,aAAa,IAAI,IAAI;gBAC1C,YAAY,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,YAAY,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI;aACnE;SACF,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CACJ,MAAc,EACd,EAAU,EACV,KAOC;QAED,MAAM,UAAU,GAAQ,EAAE,CAAC;QAE3B,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS;YAAE,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;QAC3D,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC7B,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;YAE7B,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBAC3B,UAAU,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC;gBAC9C,UAAU,CAAC,IAAI,GAAG,IAAI,CAAC;YACzB,CAAC;iBAAM,CAAC;gBACN,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC;gBACrC,UAAU,CAAC,QAAQ,GAAG,IAAI,CAAC;YAC7B,CAAC;QACH,CAAC;aAAM,CAAC;YAEN,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS;gBAAE,UAAU,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAC;YACvE,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS;gBAAE,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;QAC7D,CAAC;QAGD,IAAI,KAAK,CAAC,aAAa,KAAK,SAAS;YACnC,UAAU,CAAC,aAAa,GAAG,KAAK,CAAC,aAAa,IAAI,IAAI,CAAC;QACzD,IAAI,KAAK,CAAC,YAAY,KAAK,SAAS;YAClC,UAAU,CAAC,YAAY,GAAG,KAAK,CAAC,YAAY,IAAI,IAAI,CAAC;QAEvD,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;YACrB,IAAI,EAAE,UAAU;SACjB,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CAAC,MAAc,EAAE,EAAU;QAE/B,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;YACrB,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,EAAE;SAChC,CAAC,CAAC;IACL,CAAC;CACF,CAAA;AArFY,wCAAc;yBAAd,cAAc;IAD1B,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,cAAc,CAqF1B"}
|
||||
{"version":3,"file":"wallets.service.js","sourceRoot":"","sources":["../../src/wallets/wallets.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA4C;AAC5C,6DAAyD;AAGlD,IAAM,cAAc,GAApB,MAAM,cAAc;IACL;IAApB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,IAAI,CAAC,MAAc;QACjB,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC;YACjC,KAAK,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE;YAClC,OAAO,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE;SAC9B,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CACJ,MAAc,EACd,KAOC;QAED,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,OAAO,CAAC;QACnC,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,IAAI,EAAE;gBACJ,MAAM;gBACN,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,IAAI;gBACJ,QAAQ,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI;gBAC7D,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI;gBACpD,aAAa,EAAE,KAAK,CAAC,aAAa,IAAI,IAAI;gBAC1C,YAAY,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,YAAY,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI;aACnE;SACF,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CACJ,MAAc,EACd,EAAU,EACV,KAOC;QAED,MAAM,UAAU,GAAQ,EAAE,CAAC;QAE3B,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS;YAAE,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;QAC3D,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC7B,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;YAE7B,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBAC3B,UAAU,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC;gBAC9C,UAAU,CAAC,IAAI,GAAG,IAAI,CAAC;YACzB,CAAC;iBAAM,CAAC;gBACN,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC;gBACrC,UAAU,CAAC,QAAQ,GAAG,IAAI,CAAC;YAC7B,CAAC;QACH,CAAC;aAAM,CAAC;YAEN,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS;gBAAE,UAAU,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAC;YACvE,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS;gBAAE,UAAU,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;QAC7D,CAAC;QAGD,IAAI,KAAK,CAAC,aAAa,KAAK,SAAS;YACnC,UAAU,CAAC,aAAa,GAAG,KAAK,CAAC,aAAa,IAAI,IAAI,CAAC;QACzD,IAAI,KAAK,CAAC,YAAY,KAAK,SAAS;YAClC,UAAU,CAAC,YAAY,GAAG,KAAK,CAAC,YAAY,IAAI,IAAI,CAAC;QAEvD,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;YACrB,IAAI,EAAE,UAAU;SACjB,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,gBAAgB,CACpB,MAAc,EACd,OAA0D;QAG1D,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,YAAY,CAC5C,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CACrB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YACxB,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE;YACrD,IAAI,EAAE,EAAE,YAAY,EAAE,MAAM,CAAC,YAAY,EAAE;SAC5C,CAAC,CACH,CACF,CAAC;QAEF,OAAO;YACL,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,OAAO,CAAC,MAAM;YACvB,OAAO,EAAE,OAAO;SACjB,CAAC;IACJ,CAAC;IAED,MAAM,CAAC,MAAc,EAAE,EAAU;QAE/B,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/B,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;YACrB,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,EAAE;SAChC,CAAC,CAAC;IACL,CAAC;CACF,CAAA;AA1GY,wCAAc;yBAAd,cAAc;IAD1B,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,cAAc,CA0G1B"}
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
239
apps/web/src/components/dialogs/AssetPriceUpdateDialog.tsx
Normal file
239
apps/web/src/components/dialogs/AssetPriceUpdateDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
82
apps/web/src/components/ui/floating-action-button.tsx
Normal file
82
apps/web/src/components/ui/floating-action-button.tsx
Normal 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 }
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user