Compare commits
2 Commits
c0df4a7c2a
...
bfd009368a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfd009368a | ||
|
|
371b5e0a66 |
87
MULTI_LANGUAGE_IMPLEMENTATION.md
Normal file
87
MULTI_LANGUAGE_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Multi-Language Implementation
|
||||
|
||||
## Overview
|
||||
Implemented a simple, lightweight multi-language system for the member dashboard with Indonesian (default) and English support.
|
||||
|
||||
## Features
|
||||
- ✅ **Default Language**: Indonesian (ID)
|
||||
- ✅ **Optional Language**: English (EN)
|
||||
- ✅ **Persistent**: Language preference saved in localStorage
|
||||
- ✅ **Easy Toggle**: Language switcher in sidebar footer
|
||||
- ✅ **Type-Safe**: Full TypeScript support with autocomplete
|
||||
|
||||
## Structure
|
||||
|
||||
### Translation Files
|
||||
- `/apps/web/src/locales/id.ts` - Indonesian translations
|
||||
- `/apps/web/src/locales/en.ts` - English translations
|
||||
|
||||
### Context & Hook
|
||||
- `/apps/web/src/contexts/LanguageContext.tsx` - Language context provider
|
||||
- Hook: `useLanguage()` - Access translations and language state
|
||||
|
||||
### Components
|
||||
- `/apps/web/src/components/LanguageToggle.tsx` - Language switcher button
|
||||
|
||||
## Usage
|
||||
|
||||
### In Components
|
||||
```typescript
|
||||
import { useLanguage } from '@/contexts/LanguageContext'
|
||||
|
||||
function MyComponent() {
|
||||
const { t, language, setLanguage } = useLanguage()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t.overview.title}</h1>
|
||||
<p>{t.overview.totalBalance}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Translation Structure
|
||||
```typescript
|
||||
{
|
||||
common: { search, filter, add, edit, delete, ... },
|
||||
nav: { overview, transactions, wallets, profile, logout },
|
||||
overview: { ... },
|
||||
transactions: { ... },
|
||||
wallets: { ... },
|
||||
profile: { ... },
|
||||
// etc
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### ✅ Completed
|
||||
1. Translation files (ID & EN)
|
||||
2. Language context & provider
|
||||
3. Language toggle component
|
||||
4. Sidebar navigation translated
|
||||
5. Build & lint passing
|
||||
|
||||
### 🔄 Next Steps (To be implemented)
|
||||
1. Translate all member dashboard pages:
|
||||
- Overview page
|
||||
- Wallets page
|
||||
- Transactions page
|
||||
- Profile page
|
||||
2. Translate dialogs:
|
||||
- WalletDialog
|
||||
- TransactionDialog
|
||||
3. Update toast messages to use translations
|
||||
4. Test language switching
|
||||
|
||||
## Admin Dashboard
|
||||
- **Remains English-only** (as requested)
|
||||
- No translation needed for admin pages
|
||||
|
||||
## Benefits
|
||||
- ✅ No external dependencies
|
||||
- ✅ Type-safe with autocomplete
|
||||
- ✅ Easy to maintain
|
||||
- ✅ Fast performance
|
||||
- ✅ Can migrate to react-i18next later if needed
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { LanguageProvider } from './contexts/LanguageContext'
|
||||
import { ThemeProvider } from './components/ThemeProvider'
|
||||
import { Toaster } from './components/ui/sonner'
|
||||
import { Dashboard } from './components/Dashboard'
|
||||
@@ -59,9 +60,10 @@ export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider defaultTheme="light" storageKey="tabungin-ui-theme">
|
||||
<AuthProvider>
|
||||
<Toaster />
|
||||
<Routes>
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<Toaster />
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/auth/login" element={<PublicRoute><Login /></PublicRoute>} />
|
||||
<Route path="/auth/register" element={<PublicRoute><Register /></PublicRoute>} />
|
||||
@@ -81,7 +83,8 @@ export default function App() {
|
||||
{/* Protected Routes */}
|
||||
<Route path="/*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
24
apps/web/src/components/LanguageToggle.tsx
Normal file
24
apps/web/src/components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useLanguage } from '@/contexts/LanguageContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Languages } from 'lucide-react'
|
||||
|
||||
export function LanguageToggle() {
|
||||
const { language, setLanguage } = useLanguage()
|
||||
|
||||
const toggleLanguage = () => {
|
||||
setLanguage(language === 'id' ? 'en' : 'id')
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleLanguage}
|
||||
className="gap-2"
|
||||
title={language === 'id' ? 'Switch to English' : 'Ganti ke Bahasa Indonesia'}
|
||||
>
|
||||
<Languages className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">{language === 'id' ? 'ID' : 'EN'}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useLanguage } from "@/contexts/LanguageContext"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -45,17 +46,19 @@ interface TransactionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction?: Transaction | null
|
||||
walletId?: string | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const API = "/api"
|
||||
|
||||
export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }: TransactionDialogProps) {
|
||||
export function TransactionDialog({ open, onOpenChange, transaction, walletId: initialWalletId, onSuccess }: TransactionDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [wallets, setWallets] = useState<Wallet[]>([])
|
||||
const [categoryOptions, setCategoryOptions] = useState<Option[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [amount, setAmount] = useState(transaction?.amount?.toString() || "")
|
||||
const [walletId, setWalletId] = useState(transaction?.walletId || "")
|
||||
const [walletId, setWalletId] = useState(transaction?.walletId || initialWalletId || "")
|
||||
const [direction, setDirection] = useState<"in" | "out">(transaction?.direction || "out")
|
||||
const [categories, setCategories] = useState<string[]>(
|
||||
transaction?.category ? transaction.category.split(',').map(c => c.trim()) : []
|
||||
@@ -222,18 +225,18 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Transaction" : "Add New Transaction"}</DialogTitle>
|
||||
<DialogTitle>{isEditing ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing ? "Update your transaction details." : "Record a new transaction."}
|
||||
{isEditing ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wallet">Wallet</Label>
|
||||
<Label htmlFor="wallet">{t.transactionDialog.wallet}</Label>
|
||||
<Select value={walletId} onValueChange={setWalletId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a wallet" />
|
||||
<SelectValue placeholder={t.transactionDialog.selectWallet} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{wallets.map(wallet => (
|
||||
@@ -247,58 +250,58 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="amount">Amount</Label>
|
||||
<Label htmlFor="amount">{t.transactionDialog.amount}</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
placeholder={t.transactionDialog.amountPlaceholder}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="direction">Type</Label>
|
||||
<Label htmlFor="direction">{t.transactionDialog.direction}</Label>
|
||||
<Select value={direction} onValueChange={(value: "in" | "out") => setDirection(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="in">Income</SelectItem>
|
||||
<SelectItem value="out">Expense</SelectItem>
|
||||
<SelectItem value="in">{t.transactionDialog.income}</SelectItem>
|
||||
<SelectItem value="out">{t.transactionDialog.expense}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="date">Date</Label>
|
||||
<Label htmlFor="date">{t.transactionDialog.date}</Label>
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
onDateChange={(date) => date && setSelectedDate(date)}
|
||||
placeholder="Select date"
|
||||
placeholder={t.transactionDialog.selectDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category">Categories</Label>
|
||||
<Label htmlFor="category">{t.transactionDialog.category}</Label>
|
||||
<MultipleSelector
|
||||
options={categoryOptions}
|
||||
selected={categories}
|
||||
onChange={setCategories}
|
||||
placeholder="Select or create categories..."
|
||||
placeholder={t.transactionDialog.categoryPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="memo">Memo</Label>
|
||||
<Label htmlFor="memo">{t.transactionDialog.memo}</Label>
|
||||
<Input
|
||||
id="memo"
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
placeholder={t.transactionDialog.memoPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -310,10 +313,10 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : isEditing ? "Update" : "Create"}
|
||||
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useLanguage } from "@/contexts/LanguageContext"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -56,6 +57,7 @@ interface WalletDialogProps {
|
||||
const API = "/api"
|
||||
|
||||
export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [name, setName] = useState(wallet?.name || "")
|
||||
const [kind, setKind] = useState<"money" | "asset">(wallet?.kind || "money")
|
||||
const [currency, setCurrency] = useState(wallet?.currency || "IDR")
|
||||
@@ -71,7 +73,7 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
setError("Name is required")
|
||||
setError(t.walletDialog.name + " is required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -141,40 +143,40 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Wallet" : "Add New Wallet"}</DialogTitle>
|
||||
<DialogTitle>{isEditing ? t.walletDialog.editTitle : t.walletDialog.addTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing ? "Update your wallet details." : "Create a new wallet to track your finances."}
|
||||
{isEditing ? t.walletDialog.editTitle : t.walletDialog.addTitle}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Label htmlFor="name">{t.walletDialog.name}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., My Bank Account"
|
||||
placeholder={t.walletDialog.namePlaceholder}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="kind">Type</Label>
|
||||
<Label htmlFor="kind">{t.walletDialog.type}</Label>
|
||||
<Select value={kind} onValueChange={(value: "money" | "asset") => setKind(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="money">Money</SelectItem>
|
||||
<SelectItem value="asset">Asset</SelectItem>
|
||||
<SelectItem value="money">{t.walletDialog.money}</SelectItem>
|
||||
<SelectItem value="asset">{t.walletDialog.asset}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{kind === "money" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Label htmlFor="currency">{t.walletDialog.currency}</Label>
|
||||
<Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -185,13 +187,13 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
|
||||
>
|
||||
{currency
|
||||
? CURRENCIES.find((curr) => curr.code === currency)?.code + " - " + CURRENCIES.find((curr) => curr.code === currency)?.name
|
||||
: "Select currency..."}
|
||||
: t.walletDialog.selectCurrency}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search currency..." />
|
||||
<CommandInput placeholder={t.common.search + " currency..."} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No currency found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
@@ -222,37 +224,37 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="unit">Unit</Label>
|
||||
<Label htmlFor="unit">{t.walletDialog.unit}</Label>
|
||||
<Input
|
||||
id="unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
placeholder="e.g., shares, kg, pieces"
|
||||
placeholder={t.walletDialog.unitPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pricePerUnit">Price per Unit (IDR)</Label>
|
||||
<Label htmlFor="pricePerUnit">{t.walletDialog.pricePerUnit}</Label>
|
||||
<Input
|
||||
id="pricePerUnit"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={pricePerUnit}
|
||||
onChange={(e) => setPricePerUnit(e.target.value)}
|
||||
placeholder="0.00"
|
||||
placeholder={t.walletDialog.pricePerUnitPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="initialAmount">Initial Amount (Optional)</Label>
|
||||
<Label htmlFor="initialAmount">{t.walletDialog.initialAmount}</Label>
|
||||
<Input
|
||||
id="initialAmount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={initialAmount}
|
||||
onChange={(e) => setInitialAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
placeholder={t.walletDialog.initialAmountPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -264,10 +266,10 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : isEditing ? "Update" : "Create"}
|
||||
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Home, Wallet, Receipt, User, LogOut } from "lucide-react"
|
||||
import { Logo } from "../Logo"
|
||||
import { LanguageToggle } from "../LanguageToggle"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -13,31 +14,9 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useAuth } from "@/contexts/AuthContext"
|
||||
import { useLanguage } from "@/contexts/LanguageContext"
|
||||
import { getAvatarUrl } from "@/lib/utils"
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Overview",
|
||||
url: "/",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: "Wallets",
|
||||
url: "/wallets",
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
title: "Transactions",
|
||||
url: "/transactions",
|
||||
icon: Receipt,
|
||||
},
|
||||
{
|
||||
title: "Profile",
|
||||
url: "/profile",
|
||||
icon: User,
|
||||
},
|
||||
]
|
||||
|
||||
interface AppSidebarProps {
|
||||
currentPage: string
|
||||
onNavigate: (page: string) => void
|
||||
@@ -46,6 +25,30 @@ interface AppSidebarProps {
|
||||
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
const { user, logout } = useAuth()
|
||||
const { isMobile, setOpenMobile } = useSidebar()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: t.nav.overview,
|
||||
url: "/",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: t.nav.wallets,
|
||||
url: "/wallets",
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
title: t.nav.transactions,
|
||||
url: "/transactions",
|
||||
icon: Receipt,
|
||||
},
|
||||
{
|
||||
title: t.nav.profile,
|
||||
url: "/profile",
|
||||
icon: User,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
@@ -109,13 +112,16 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full mt-3 flex items-center justify-center px-4 py-2 text-sm font-medium text-destructive bg-destructive/10 rounded-lg hover:bg-destructive/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</button>
|
||||
<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>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { useLanguage } from "@/contexts/LanguageContext"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -49,6 +50,7 @@ interface Wallet {
|
||||
const API = "/api"
|
||||
|
||||
export function Wallets() {
|
||||
const { t } = useLanguage()
|
||||
const [wallets, setWallets] = useState<Wallet[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
@@ -165,7 +167,7 @@ export function Wallets() {
|
||||
</Button>
|
||||
<Button onClick={() => setWalletDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Wallet
|
||||
{t.wallets.addWallet}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,7 +177,7 @@ export function Wallets() {
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Wallets</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t.wallets.title}</CardTitle>
|
||||
<Wallet className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -185,7 +187,7 @@ export function Wallets() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Money Wallets</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t.wallets.moneyWallets}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.moneyWallets}</div>
|
||||
@@ -194,7 +196,7 @@ export function Wallets() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Asset Wallets</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t.wallets.assetWallets}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.assetWallets}</div>
|
||||
@@ -203,7 +205,7 @@ export function Wallets() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Currencies</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t.wallets.currency}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.currencyCount}</div>
|
||||
@@ -216,12 +218,12 @@ export function Wallets() {
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Filters</CardTitle>
|
||||
<CardTitle className="text-base">{t.common.filter}</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-8 text-xs"
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Clear All
|
||||
@@ -233,11 +235,11 @@ 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">Search Wallet</Label>
|
||||
<Label className="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" />
|
||||
<Input
|
||||
placeholder="Search wallets..."
|
||||
placeholder={t.wallets.searchPlaceholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
@@ -247,28 +249,28 @@ export function Wallets() {
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Type</Label>
|
||||
<Label className="text-xs font-medium text-muted-foreground">{t.wallets.type}</Label>
|
||||
<Select value={kindFilter} onValueChange={setKindFilter}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All types" />
|
||||
<SelectValue placeholder={t.common.all} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="money">Money</SelectItem>
|
||||
<SelectItem value="asset">Asset</SelectItem>
|
||||
<SelectItem value="all">{t.common.all}</SelectItem>
|
||||
<SelectItem value="money">{t.wallets.money}</SelectItem>
|
||||
<SelectItem value="asset">{t.wallets.asset}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Currency Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Currency/Unit</Label>
|
||||
<Label className="text-xs font-medium text-muted-foreground">{t.wallets.currency}/{t.wallets.unit}</Label>
|
||||
<Select value={currencyFilter} onValueChange={setCurrencyFilter}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All currencies" />
|
||||
<SelectValue placeholder={t.common.all} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Currencies/Units</SelectItem>
|
||||
<SelectItem value="all">{t.common.all}</SelectItem>
|
||||
{availableCurrencies.map(currency => (
|
||||
<SelectItem key={currency} value={currency}>
|
||||
{currency}
|
||||
@@ -316,7 +318,7 @@ export function Wallets() {
|
||||
{/* Wallets Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wallets ({filteredWallets.length})</CardTitle>
|
||||
<CardTitle>{t.wallets.title} ({filteredWallets.length})</CardTitle>
|
||||
<CardDescription>
|
||||
{filteredWallets.length !== wallets.length
|
||||
? `Filtered from ${wallets.length} total wallets`
|
||||
@@ -328,11 +330,11 @@ export function Wallets() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Currency/Unit</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -340,8 +342,8 @@ export function Wallets() {
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8">
|
||||
{filteredWallets.length !== wallets.length
|
||||
? "No wallets match your filters"
|
||||
: "No wallets found. Create your first wallet!"
|
||||
? t.wallets.noWallets
|
||||
: t.wallets.createFirst
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -380,15 +382,15 @@ export function Wallets() {
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Wallet</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t.common.delete} {t.wallets.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{wallet.name}"? This action cannot be undone.
|
||||
{t.wallets.deleteConfirm}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteWallet(wallet.id)}>
|
||||
Delete
|
||||
{t.common.delete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
58
apps/web/src/contexts/LanguageContext.tsx
Normal file
58
apps/web/src/contexts/LanguageContext.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { id } from '@/locales/id'
|
||||
import { en } from '@/locales/en'
|
||||
|
||||
type Language = 'id' | 'en'
|
||||
type Translations = typeof id
|
||||
|
||||
interface LanguageContextType {
|
||||
language: Language
|
||||
setLanguage: (lang: Language) => void
|
||||
t: Translations
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined)
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>(() => {
|
||||
const stored = localStorage.getItem('language')
|
||||
return (stored === 'en' || stored === 'id') ? stored : 'id' // Default to Indonesian
|
||||
})
|
||||
|
||||
const translations: Record<Language, Translations> = {
|
||||
id,
|
||||
en,
|
||||
}
|
||||
|
||||
const setLanguage = (lang: Language) => {
|
||||
setLanguageState(lang)
|
||||
localStorage.setItem('language', lang)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Save to localStorage whenever language changes
|
||||
localStorage.setItem('language', language)
|
||||
}, [language])
|
||||
|
||||
const value: LanguageContextType = {
|
||||
language,
|
||||
setLanguage,
|
||||
t: translations[language],
|
||||
}
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={value}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useLanguage must be used within a LanguageProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
210
apps/web/src/locales/en.ts
Normal file
210
apps/web/src/locales/en.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
export const en = {
|
||||
common: {
|
||||
search: 'Search',
|
||||
filter: 'Filter',
|
||||
add: 'Add',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
close: 'Close',
|
||||
loading: 'Loading...',
|
||||
noData: 'No data',
|
||||
confirm: 'Confirm',
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
total: 'Total',
|
||||
date: 'Date',
|
||||
amount: 'Amount',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
all: 'All',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
},
|
||||
|
||||
nav: {
|
||||
overview: 'Overview',
|
||||
transactions: 'Transactions',
|
||||
wallets: 'Wallets',
|
||||
profile: 'Profile',
|
||||
logout: 'Logout',
|
||||
},
|
||||
|
||||
overview: {
|
||||
title: 'Overview',
|
||||
totalBalance: 'Total Balance',
|
||||
totalIncome: 'Total Income',
|
||||
totalExpense: 'Total Expense',
|
||||
acrossWallets: 'Across {count} wallets',
|
||||
income: 'income',
|
||||
expense: 'expense',
|
||||
recentTransactions: 'Recent Transactions',
|
||||
viewAll: 'View All',
|
||||
noTransactions: 'No transactions yet',
|
||||
addFirstTransaction: 'Add your first transaction',
|
||||
wallets: 'Wallets',
|
||||
addWallet: 'Add Wallet',
|
||||
noWallets: 'No wallets yet',
|
||||
createFirstWallet: 'Create your first wallet',
|
||||
incomeByCategory: 'Income by Category',
|
||||
expenseByCategory: 'Expense by Category',
|
||||
last30Days: 'Last 30 days',
|
||||
last7Days: 'Last 7 days',
|
||||
thisMonth: 'This month',
|
||||
lastMonth: 'Last month',
|
||||
thisYear: 'This year',
|
||||
custom: 'Custom',
|
||||
},
|
||||
|
||||
transactions: {
|
||||
title: 'Transactions',
|
||||
addTransaction: 'Add Transaction',
|
||||
editTransaction: 'Edit Transaction',
|
||||
deleteConfirm: 'Are you sure you want to delete this transaction?',
|
||||
income: 'Income',
|
||||
expense: 'Expense',
|
||||
category: 'Category',
|
||||
memo: 'Memo',
|
||||
wallet: 'Wallet',
|
||||
direction: 'Type',
|
||||
filterByWallet: 'Filter by Wallet',
|
||||
filterByDirection: 'Filter by Type',
|
||||
filterByCategory: 'Filter by Category',
|
||||
searchPlaceholder: 'Search transactions...',
|
||||
noTransactions: 'No transactions',
|
||||
stats: {
|
||||
totalIncome: 'Total Income',
|
||||
totalExpense: 'Total Expense',
|
||||
netAmount: 'Net Amount',
|
||||
},
|
||||
},
|
||||
|
||||
wallets: {
|
||||
title: 'Wallets',
|
||||
addWallet: 'Add Wallet',
|
||||
editWallet: 'Edit Wallet',
|
||||
deleteConfirm: 'Are you sure you want to delete this wallet? All related transactions will be deleted.',
|
||||
name: 'Name',
|
||||
type: 'Type',
|
||||
balance: 'Balance',
|
||||
currency: 'Currency',
|
||||
unit: 'Unit',
|
||||
initialAmount: 'Initial Amount',
|
||||
pricePerUnit: 'Price per Unit',
|
||||
money: 'Money',
|
||||
asset: 'Asset',
|
||||
filterByType: 'Filter by Type',
|
||||
searchPlaceholder: 'Search wallets...',
|
||||
noWallets: 'No wallets yet',
|
||||
createFirst: 'Create your first wallet to start tracking your finances',
|
||||
totalBalance: 'Total Balance',
|
||||
moneyWallets: 'Money Wallets',
|
||||
assetWallets: 'Asset Wallets',
|
||||
},
|
||||
|
||||
walletDialog: {
|
||||
addTitle: 'Add Wallet',
|
||||
editTitle: 'Edit Wallet',
|
||||
name: 'Wallet Name',
|
||||
namePlaceholder: 'e.g., Main Wallet, Savings',
|
||||
type: 'Wallet Type',
|
||||
money: 'Money',
|
||||
asset: 'Asset',
|
||||
currency: 'Currency',
|
||||
selectCurrency: 'Select currency',
|
||||
unit: 'Unit',
|
||||
unitPlaceholder: 'e.g., grams, lots, shares',
|
||||
initialAmount: 'Initial Amount (Optional)',
|
||||
initialAmountPlaceholder: '0',
|
||||
pricePerUnit: 'Price per Unit (Optional)',
|
||||
pricePerUnitPlaceholder: '0',
|
||||
pricePerUnitHelper: 'Price per {unit} in IDR',
|
||||
},
|
||||
|
||||
transactionDialog: {
|
||||
addTitle: 'Add Transaction',
|
||||
editTitle: 'Edit Transaction',
|
||||
amount: 'Amount',
|
||||
amountPlaceholder: '0',
|
||||
wallet: 'Wallet',
|
||||
selectWallet: 'Select wallet',
|
||||
direction: 'Transaction Type',
|
||||
income: 'Income',
|
||||
expense: 'Expense',
|
||||
category: 'Category',
|
||||
categoryPlaceholder: 'Select or type new category',
|
||||
addCategory: 'Add',
|
||||
memo: 'Memo (Optional)',
|
||||
memoPlaceholder: 'Add a note...',
|
||||
date: 'Date',
|
||||
selectDate: 'Select date',
|
||||
},
|
||||
|
||||
profile: {
|
||||
title: 'Profile',
|
||||
personalInfo: 'Personal Information',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
emailVerified: 'Email Verified',
|
||||
emailNotVerified: 'Email Not Verified',
|
||||
avatar: 'Avatar',
|
||||
changeAvatar: 'Change Avatar',
|
||||
uploading: 'Uploading...',
|
||||
|
||||
security: 'Security',
|
||||
password: 'Password',
|
||||
currentPassword: 'Current Password',
|
||||
newPassword: 'New Password',
|
||||
confirmPassword: 'Confirm New Password',
|
||||
changePassword: 'Change Password',
|
||||
setPassword: 'Set Password',
|
||||
noPassword: 'You logged in with Google and haven\'t set a password yet',
|
||||
|
||||
twoFactor: 'Two-Factor Authentication',
|
||||
twoFactorDesc: 'Add an extra layer of security to your account',
|
||||
phoneNumber: 'Phone Number',
|
||||
phoneNumberPlaceholder: '+62812345678',
|
||||
updatePhone: 'Update Phone',
|
||||
|
||||
emailOtp: 'Email OTP',
|
||||
emailOtpDesc: 'Receive verification codes via email',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
sendCode: 'Send Code',
|
||||
verifyCode: 'Verify Code',
|
||||
enterCode: 'Enter code',
|
||||
|
||||
whatsappOtp: 'WhatsApp OTP',
|
||||
whatsappOtpDesc: 'Receive verification codes via WhatsApp',
|
||||
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDesc: 'Use an authenticator app like Google Authenticator',
|
||||
setup: 'Setup',
|
||||
scanQr: 'Scan QR Code',
|
||||
scanQrDesc: 'Scan this QR code with your authenticator app',
|
||||
manualEntry: 'Or enter this code manually:',
|
||||
enterAuthCode: 'Enter code from your authenticator app',
|
||||
|
||||
dangerZone: 'Danger Zone',
|
||||
deleteAccount: 'Delete Account',
|
||||
deleteAccountDesc: 'Permanently delete your account. This action cannot be undone.',
|
||||
deleteAccountConfirm: 'Are you sure you want to delete your account? All data will be permanently lost.',
|
||||
enterPasswordToDelete: 'Enter your password to confirm',
|
||||
},
|
||||
|
||||
dateRange: {
|
||||
last7Days: 'Last 7 days',
|
||||
last30Days: 'Last 30 days',
|
||||
thisMonth: 'This month',
|
||||
lastMonth: 'Last month',
|
||||
thisYear: 'This year',
|
||||
custom: 'Custom',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
},
|
||||
}
|
||||
210
apps/web/src/locales/id.ts
Normal file
210
apps/web/src/locales/id.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
export const id = {
|
||||
common: {
|
||||
search: 'Cari',
|
||||
filter: 'Filter',
|
||||
add: 'Tambah',
|
||||
edit: 'Edit',
|
||||
delete: 'Hapus',
|
||||
cancel: 'Batal',
|
||||
save: 'Simpan',
|
||||
close: 'Tutup',
|
||||
loading: 'Memuat...',
|
||||
noData: 'Tidak ada data',
|
||||
confirm: 'Konfirmasi',
|
||||
success: 'Berhasil',
|
||||
error: 'Gagal',
|
||||
total: 'Total',
|
||||
date: 'Tanggal',
|
||||
amount: 'Jumlah',
|
||||
status: 'Status',
|
||||
actions: 'Aksi',
|
||||
all: 'Semua',
|
||||
active: 'Aktif',
|
||||
inactive: 'Tidak Aktif',
|
||||
yes: 'Ya',
|
||||
no: 'Tidak',
|
||||
},
|
||||
|
||||
nav: {
|
||||
overview: 'Ringkasan',
|
||||
transactions: 'Transaksi',
|
||||
wallets: 'Dompet',
|
||||
profile: 'Profil',
|
||||
logout: 'Keluar',
|
||||
},
|
||||
|
||||
overview: {
|
||||
title: 'Ringkasan',
|
||||
totalBalance: 'Total Saldo',
|
||||
totalIncome: 'Total Pemasukan',
|
||||
totalExpense: 'Total Pengeluaran',
|
||||
acrossWallets: 'Dari {count} dompet',
|
||||
income: 'pemasukan',
|
||||
expense: 'pengeluaran',
|
||||
recentTransactions: 'Transaksi Terkini',
|
||||
viewAll: 'Lihat Semua',
|
||||
noTransactions: 'Belum ada transaksi',
|
||||
addFirstTransaction: 'Tambahkan transaksi pertama Anda',
|
||||
wallets: 'Dompet',
|
||||
addWallet: 'Tambah Dompet',
|
||||
noWallets: 'Belum ada dompet',
|
||||
createFirstWallet: 'Buat dompet pertama Anda',
|
||||
incomeByCategory: 'Pemasukan per Kategori',
|
||||
expenseByCategory: 'Pengeluaran per Kategori',
|
||||
last30Days: '30 hari terakhir',
|
||||
last7Days: '7 hari terakhir',
|
||||
thisMonth: 'Bulan ini',
|
||||
lastMonth: 'Bulan lalu',
|
||||
thisYear: 'Tahun ini',
|
||||
custom: 'Kustom',
|
||||
},
|
||||
|
||||
transactions: {
|
||||
title: 'Transaksi',
|
||||
addTransaction: 'Tambah Transaksi',
|
||||
editTransaction: 'Edit Transaksi',
|
||||
deleteConfirm: 'Apakah Anda yakin ingin menghapus transaksi ini?',
|
||||
income: 'Pemasukan',
|
||||
expense: 'Pengeluaran',
|
||||
category: 'Kategori',
|
||||
memo: 'Catatan',
|
||||
wallet: 'Dompet',
|
||||
direction: 'Tipe',
|
||||
filterByWallet: 'Filter berdasarkan Dompet',
|
||||
filterByDirection: 'Filter berdasarkan Tipe',
|
||||
filterByCategory: 'Filter berdasarkan Kategori',
|
||||
searchPlaceholder: 'Cari transaksi...',
|
||||
noTransactions: 'Tidak ada transaksi',
|
||||
stats: {
|
||||
totalIncome: 'Total Pemasukan',
|
||||
totalExpense: 'Total Pengeluaran',
|
||||
netAmount: 'Saldo Bersih',
|
||||
},
|
||||
},
|
||||
|
||||
wallets: {
|
||||
title: 'Dompet',
|
||||
addWallet: 'Tambah Dompet',
|
||||
editWallet: 'Edit Dompet',
|
||||
deleteConfirm: 'Apakah Anda yakin ingin menghapus dompet ini? Semua transaksi terkait akan ikut terhapus.',
|
||||
name: 'Nama',
|
||||
type: 'Tipe',
|
||||
balance: 'Saldo',
|
||||
currency: 'Mata Uang',
|
||||
unit: 'Satuan',
|
||||
initialAmount: 'Jumlah Awal',
|
||||
pricePerUnit: 'Harga per Satuan',
|
||||
money: 'Uang',
|
||||
asset: 'Aset',
|
||||
filterByType: 'Filter berdasarkan Tipe',
|
||||
searchPlaceholder: 'Cari dompet...',
|
||||
noWallets: 'Belum ada dompet',
|
||||
createFirst: 'Buat dompet pertama Anda untuk mulai melacak keuangan',
|
||||
totalBalance: 'Total Saldo',
|
||||
moneyWallets: 'Dompet Uang',
|
||||
assetWallets: 'Dompet Aset',
|
||||
},
|
||||
|
||||
walletDialog: {
|
||||
addTitle: 'Tambah Dompet',
|
||||
editTitle: 'Edit Dompet',
|
||||
name: 'Nama Dompet',
|
||||
namePlaceholder: 'Contoh: Dompet Utama, Tabungan',
|
||||
type: 'Tipe Dompet',
|
||||
money: 'Uang',
|
||||
asset: 'Aset',
|
||||
currency: 'Mata Uang',
|
||||
selectCurrency: 'Pilih mata uang',
|
||||
unit: 'Satuan',
|
||||
unitPlaceholder: 'Contoh: gram, lot, lembar',
|
||||
initialAmount: 'Jumlah Awal (Opsional)',
|
||||
initialAmountPlaceholder: '0',
|
||||
pricePerUnit: 'Harga per Satuan (Opsional)',
|
||||
pricePerUnitPlaceholder: '0',
|
||||
pricePerUnitHelper: 'Harga per {unit} dalam IDR',
|
||||
},
|
||||
|
||||
transactionDialog: {
|
||||
addTitle: 'Tambah Transaksi',
|
||||
editTitle: 'Edit Transaksi',
|
||||
amount: 'Jumlah',
|
||||
amountPlaceholder: '0',
|
||||
wallet: 'Dompet',
|
||||
selectWallet: 'Pilih dompet',
|
||||
direction: 'Tipe Transaksi',
|
||||
income: 'Pemasukan',
|
||||
expense: 'Pengeluaran',
|
||||
category: 'Kategori',
|
||||
categoryPlaceholder: 'Pilih atau ketik kategori baru',
|
||||
addCategory: 'Tambah',
|
||||
memo: 'Catatan (Opsional)',
|
||||
memoPlaceholder: 'Tambahkan catatan...',
|
||||
date: 'Tanggal',
|
||||
selectDate: 'Pilih tanggal',
|
||||
},
|
||||
|
||||
profile: {
|
||||
title: 'Profil',
|
||||
personalInfo: 'Informasi Pribadi',
|
||||
name: 'Nama',
|
||||
email: 'Email',
|
||||
emailVerified: 'Email Terverifikasi',
|
||||
emailNotVerified: 'Email Belum Terverifikasi',
|
||||
avatar: 'Avatar',
|
||||
changeAvatar: 'Ubah Avatar',
|
||||
uploading: 'Mengunggah...',
|
||||
|
||||
security: 'Keamanan',
|
||||
password: 'Password',
|
||||
currentPassword: 'Password Saat Ini',
|
||||
newPassword: 'Password Baru',
|
||||
confirmPassword: 'Konfirmasi Password Baru',
|
||||
changePassword: 'Ubah Password',
|
||||
setPassword: 'Atur Password',
|
||||
noPassword: 'Anda login dengan Google dan belum mengatur password',
|
||||
|
||||
twoFactor: 'Autentikasi Dua Faktor',
|
||||
twoFactorDesc: 'Tambahkan lapisan keamanan ekstra ke akun Anda',
|
||||
phoneNumber: 'Nomor Telepon',
|
||||
phoneNumberPlaceholder: '+62812345678',
|
||||
updatePhone: 'Update Nomor',
|
||||
|
||||
emailOtp: 'Email OTP',
|
||||
emailOtpDesc: 'Terima kode verifikasi via email',
|
||||
enable: 'Aktifkan',
|
||||
disable: 'Nonaktifkan',
|
||||
enabled: 'Aktif',
|
||||
disabled: 'Tidak Aktif',
|
||||
sendCode: 'Kirim Kode',
|
||||
verifyCode: 'Verifikasi Kode',
|
||||
enterCode: 'Masukkan kode',
|
||||
|
||||
whatsappOtp: 'WhatsApp OTP',
|
||||
whatsappOtpDesc: 'Terima kode verifikasi via WhatsApp',
|
||||
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDesc: 'Gunakan aplikasi authenticator seperti Google Authenticator',
|
||||
setup: 'Setup',
|
||||
scanQr: 'Scan QR Code',
|
||||
scanQrDesc: 'Scan QR code ini dengan aplikasi authenticator Anda',
|
||||
manualEntry: 'Atau masukkan kode ini secara manual:',
|
||||
enterAuthCode: 'Masukkan kode dari aplikator authenticator',
|
||||
|
||||
dangerZone: 'Zona Berbahaya',
|
||||
deleteAccount: 'Hapus Akun',
|
||||
deleteAccountDesc: 'Hapus akun Anda secara permanen. Tindakan ini tidak dapat dibatalkan.',
|
||||
deleteAccountConfirm: 'Apakah Anda yakin ingin menghapus akun Anda? Semua data akan hilang permanen.',
|
||||
enterPasswordToDelete: 'Masukkan password Anda untuk konfirmasi',
|
||||
},
|
||||
|
||||
dateRange: {
|
||||
last7Days: '7 hari terakhir',
|
||||
last30Days: '30 hari terakhir',
|
||||
thisMonth: 'Bulan ini',
|
||||
lastMonth: 'Bulan lalu',
|
||||
thisYear: 'Tahun ini',
|
||||
custom: 'Kustom',
|
||||
from: 'Dari',
|
||||
to: 'Sampai',
|
||||
},
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/breadcrumb.tsx","./src/components/dashboard.tsx","./src/components/logo.tsx","./src/components/themeprovider.tsx","./src/components/themetoggle.tsx","./src/components/admin/adminbreadcrumb.tsx","./src/components/admin/adminlayout.tsx","./src/components/admin/adminsidebar.tsx","./src/components/admin/pages/admindashboard.tsx","./src/components/admin/pages/adminpaymentmethods.tsx","./src/components/admin/pages/adminpayments.tsx","./src/components/admin/pages/adminplans.tsx","./src/components/admin/pages/adminsettings.tsx","./src/components/admin/pages/adminusers.tsx","./src/components/dialogs/transactiondialog.tsx","./src/components/dialogs/walletdialog.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/authlayout.tsx","./src/components/layout/dashboardlayout.tsx","./src/components/pages/authcallback.tsx","./src/components/pages/login.tsx","./src/components/pages/otpverification.tsx","./src/components/pages/overview.tsx","./src/components/pages/profile.tsx","./src/components/pages/register.tsx","./src/components/pages/transactions.tsx","./src/components/pages/wallets.tsx","./src/components/test/calendartest.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/command.tsx","./src/components/ui/date-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-select.tsx","./src/components/ui/multiselector.tsx","./src/components/ui/popover.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/constants/currencies.ts","./src/contexts/authcontext.tsx","./src/hooks/use-mobile.ts","./src/hooks/usetheme.ts","./src/lib/utils.ts","./src/utils/exchangerate.ts","./src/utils/numberformat.ts"],"version":"5.9.2"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/breadcrumb.tsx","./src/components/dashboard.tsx","./src/components/languagetoggle.tsx","./src/components/logo.tsx","./src/components/themeprovider.tsx","./src/components/themetoggle.tsx","./src/components/admin/adminbreadcrumb.tsx","./src/components/admin/adminlayout.tsx","./src/components/admin/adminsidebar.tsx","./src/components/admin/pages/admindashboard.tsx","./src/components/admin/pages/adminpaymentmethods.tsx","./src/components/admin/pages/adminpayments.tsx","./src/components/admin/pages/adminplans.tsx","./src/components/admin/pages/adminsettings.tsx","./src/components/admin/pages/adminusers.tsx","./src/components/dialogs/transactiondialog.tsx","./src/components/dialogs/walletdialog.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/authlayout.tsx","./src/components/layout/dashboardlayout.tsx","./src/components/pages/authcallback.tsx","./src/components/pages/login.tsx","./src/components/pages/otpverification.tsx","./src/components/pages/overview.tsx","./src/components/pages/profile.tsx","./src/components/pages/register.tsx","./src/components/pages/transactions.tsx","./src/components/pages/wallets.tsx","./src/components/test/calendartest.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/command.tsx","./src/components/ui/date-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-select.tsx","./src/components/ui/multiselector.tsx","./src/components/ui/popover.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/constants/currencies.ts","./src/contexts/authcontext.tsx","./src/contexts/languagecontext.tsx","./src/hooks/use-mobile.ts","./src/hooks/usetheme.ts","./src/lib/utils.ts","./src/locales/en.ts","./src/locales/id.ts","./src/utils/exchangerate.ts","./src/utils/numberformat.ts"],"version":"5.9.2"}
|
||||
Reference in New Issue
Block a user