feat: Implement multi-language system (ID/EN) for member dashboard
- Create translation files (locales/id.ts, locales/en.ts) - Add LanguageContext with useLanguage hook - Add LanguageToggle component in sidebar - Default language: Indonesian (ID) - Translate WalletDialog and TransactionDialog - Language preference persisted in localStorage - Type-safe translations with autocomplete Next: Translate remaining pages (Overview, Wallets, Transactions, Profile)
This commit is contained in:
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 { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
|
import { LanguageProvider } from './contexts/LanguageContext'
|
||||||
import { ThemeProvider } from './components/ThemeProvider'
|
import { ThemeProvider } from './components/ThemeProvider'
|
||||||
import { Toaster } from './components/ui/sonner'
|
import { Toaster } from './components/ui/sonner'
|
||||||
import { Dashboard } from './components/Dashboard'
|
import { Dashboard } from './components/Dashboard'
|
||||||
@@ -59,9 +60,10 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider defaultTheme="light" storageKey="tabungin-ui-theme">
|
<ThemeProvider defaultTheme="light" storageKey="tabungin-ui-theme">
|
||||||
<AuthProvider>
|
<LanguageProvider>
|
||||||
<Toaster />
|
<AuthProvider>
|
||||||
<Routes>
|
<Toaster />
|
||||||
|
<Routes>
|
||||||
{/* Public Routes */}
|
{/* Public Routes */}
|
||||||
<Route path="/auth/login" element={<PublicRoute><Login /></PublicRoute>} />
|
<Route path="/auth/login" element={<PublicRoute><Login /></PublicRoute>} />
|
||||||
<Route path="/auth/register" element={<PublicRoute><Register /></PublicRoute>} />
|
<Route path="/auth/register" element={<PublicRoute><Register /></PublicRoute>} />
|
||||||
@@ -81,7 +83,8 @@ export default function App() {
|
|||||||
{/* Protected Routes */}
|
{/* Protected Routes */}
|
||||||
<Route path="/*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
<Route path="/*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</LanguageProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</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 { useState, useEffect } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -45,17 +46,19 @@ interface TransactionDialogProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
transaction?: Transaction | null
|
transaction?: Transaction | null
|
||||||
|
walletId?: string | null
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const API = "/api"
|
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 [wallets, setWallets] = useState<Wallet[]>([])
|
||||||
const [categoryOptions, setCategoryOptions] = useState<Option[]>([])
|
const [categoryOptions, setCategoryOptions] = useState<Option[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [amount, setAmount] = useState(transaction?.amount?.toString() || "")
|
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 [direction, setDirection] = useState<"in" | "out">(transaction?.direction || "out")
|
||||||
const [categories, setCategories] = useState<string[]>(
|
const [categories, setCategories] = useState<string[]>(
|
||||||
transaction?.category ? transaction.category.split(',').map(c => c.trim()) : []
|
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}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isEditing ? "Edit Transaction" : "Add New Transaction"}</DialogTitle>
|
<DialogTitle>{isEditing ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isEditing ? "Update your transaction details." : "Record a new transaction."}
|
{isEditing ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="wallet">Wallet</Label>
|
<Label htmlFor="wallet">{t.transactionDialog.wallet}</Label>
|
||||||
<Select value={walletId} onValueChange={setWalletId}>
|
<Select value={walletId} onValueChange={setWalletId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a wallet" />
|
<SelectValue placeholder={t.transactionDialog.selectWallet} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{wallets.map(wallet => (
|
{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 grid-cols-2 gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="amount">Amount</Label>
|
<Label htmlFor="amount">{t.transactionDialog.amount}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="amount"
|
id="amount"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
placeholder="0.00"
|
placeholder={t.transactionDialog.amountPlaceholder}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<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)}>
|
<Select value={direction} onValueChange={(value: "in" | "out") => setDirection(value)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="in">Income</SelectItem>
|
<SelectItem value="in">{t.transactionDialog.income}</SelectItem>
|
||||||
<SelectItem value="out">Expense</SelectItem>
|
<SelectItem value="out">{t.transactionDialog.expense}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="date">Date</Label>
|
<Label htmlFor="date">{t.transactionDialog.date}</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
date={selectedDate}
|
date={selectedDate}
|
||||||
onDateChange={(date) => date && setSelectedDate(date)}
|
onDateChange={(date) => date && setSelectedDate(date)}
|
||||||
placeholder="Select date"
|
placeholder={t.transactionDialog.selectDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="category">Categories</Label>
|
<Label htmlFor="category">{t.transactionDialog.category}</Label>
|
||||||
<MultipleSelector
|
<MultipleSelector
|
||||||
options={categoryOptions}
|
options={categoryOptions}
|
||||||
selected={categories}
|
selected={categories}
|
||||||
onChange={setCategories}
|
onChange={setCategories}
|
||||||
placeholder="Select or create categories..."
|
placeholder={t.transactionDialog.categoryPlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="memo">Memo</Label>
|
<Label htmlFor="memo">{t.transactionDialog.memo}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="memo"
|
id="memo"
|
||||||
value={memo}
|
value={memo}
|
||||||
onChange={(e) => setMemo(e.target.value)}
|
onChange={(e) => setMemo(e.target.value)}
|
||||||
placeholder="Optional description"
|
placeholder={t.transactionDialog.memoPlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -310,10 +313,10 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{loading ? "Saving..." : isEditing ? "Update" : "Create"}
|
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -56,6 +57,7 @@ interface WalletDialogProps {
|
|||||||
const API = "/api"
|
const API = "/api"
|
||||||
|
|
||||||
export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDialogProps) {
|
export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDialogProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [name, setName] = useState(wallet?.name || "")
|
const [name, setName] = useState(wallet?.name || "")
|
||||||
const [kind, setKind] = useState<"money" | "asset">(wallet?.kind || "money")
|
const [kind, setKind] = useState<"money" | "asset">(wallet?.kind || "money")
|
||||||
const [currency, setCurrency] = useState(wallet?.currency || "IDR")
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError("Name is required")
|
setError(t.walletDialog.name + " is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,40 +143,40 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
|
|||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isEditing ? "Edit Wallet" : "Add New Wallet"}</DialogTitle>
|
<DialogTitle>{isEditing ? t.walletDialog.editTitle : t.walletDialog.addTitle}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isEditing ? "Update your wallet details." : "Create a new wallet to track your finances."}
|
{isEditing ? t.walletDialog.editTitle : t.walletDialog.addTitle}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">{t.walletDialog.name}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g., My Bank Account"
|
placeholder={t.walletDialog.namePlaceholder}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<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)}>
|
<Select value={kind} onValueChange={(value: "money" | "asset") => setKind(value)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="money">Money</SelectItem>
|
<SelectItem value="money">{t.walletDialog.money}</SelectItem>
|
||||||
<SelectItem value="asset">Asset</SelectItem>
|
<SelectItem value="asset">{t.walletDialog.asset}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{kind === "money" ? (
|
{kind === "money" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="currency">Currency</Label>
|
<Label htmlFor="currency">{t.walletDialog.currency}</Label>
|
||||||
<Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
|
<Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -185,13 +187,13 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
|
|||||||
>
|
>
|
||||||
{currency
|
{currency
|
||||||
? CURRENCIES.find((curr) => curr.code === currency)?.code + " - " + CURRENCIES.find((curr) => curr.code === currency)?.name
|
? CURRENCIES.find((curr) => curr.code === currency)?.code + " - " + CURRENCIES.find((curr) => curr.code === currency)?.name
|
||||||
: "Select currency..."}
|
: t.walletDialog.selectCurrency}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-full p-0">
|
<PopoverContent className="w-full p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search currency..." />
|
<CommandInput placeholder={t.common.search + " currency..."} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No currency found.</CommandEmpty>
|
<CommandEmpty>No currency found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
@@ -222,37 +224,37 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="unit">Unit</Label>
|
<Label htmlFor="unit">{t.walletDialog.unit}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="unit"
|
id="unit"
|
||||||
value={unit}
|
value={unit}
|
||||||
onChange={(e) => setUnit(e.target.value)}
|
onChange={(e) => setUnit(e.target.value)}
|
||||||
placeholder="e.g., shares, kg, pieces"
|
placeholder={t.walletDialog.unitPlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="pricePerUnit">Price per Unit (IDR)</Label>
|
<Label htmlFor="pricePerUnit">{t.walletDialog.pricePerUnit}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="pricePerUnit"
|
id="pricePerUnit"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={pricePerUnit}
|
value={pricePerUnit}
|
||||||
onChange={(e) => setPricePerUnit(e.target.value)}
|
onChange={(e) => setPricePerUnit(e.target.value)}
|
||||||
placeholder="0.00"
|
placeholder={t.walletDialog.pricePerUnitPlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="initialAmount">Initial Amount (Optional)</Label>
|
<Label htmlFor="initialAmount">{t.walletDialog.initialAmount}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="initialAmount"
|
id="initialAmount"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={initialAmount}
|
value={initialAmount}
|
||||||
onChange={(e) => setInitialAmount(e.target.value)}
|
onChange={(e) => setInitialAmount(e.target.value)}
|
||||||
placeholder="0.00"
|
placeholder={t.walletDialog.initialAmountPlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -264,10 +266,10 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{loading ? "Saving..." : isEditing ? "Update" : "Create"}
|
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Home, Wallet, Receipt, User, LogOut } from "lucide-react"
|
import { Home, Wallet, Receipt, User, LogOut } from "lucide-react"
|
||||||
import { Logo } from "../Logo"
|
import { Logo } from "../Logo"
|
||||||
|
import { LanguageToggle } from "../LanguageToggle"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -13,31 +14,9 @@ import {
|
|||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { useAuth } from "@/contexts/AuthContext"
|
import { useAuth } from "@/contexts/AuthContext"
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext"
|
||||||
import { getAvatarUrl } from "@/lib/utils"
|
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 {
|
interface AppSidebarProps {
|
||||||
currentPage: string
|
currentPage: string
|
||||||
onNavigate: (page: string) => void
|
onNavigate: (page: string) => void
|
||||||
@@ -46,6 +25,30 @@ interface AppSidebarProps {
|
|||||||
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const { isMobile, setOpenMobile } = useSidebar()
|
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 (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
@@ -109,13 +112,16 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex gap-2 mt-3">
|
||||||
onClick={logout}
|
<LanguageToggle />
|
||||||
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"
|
<button
|
||||||
>
|
onClick={logout}
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
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
|
>
|
||||||
</button>
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
{t.nav.logout}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
|
|||||||
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