feat(01-02): wire palette.ts into all 6 dashboard components
- BillsTracker: headerGradient('bill'), InlineEditCell, amountColorClass
- VariableExpenses: headerGradient('variable_expense'), InlineEditCell, amountColorClass, palette bar chart colors
- DebtTracker: headerGradient('debt'), InlineEditCell, amountColorClass
- AvailableBalance: headerGradient('saving'), palette Cell fills, text-3xl center, text-success/text-destructive
- ExpenseBreakdown: headerGradient('variable_expense'), palette Cell fills
- FinancialOverview: overviewHeaderGradient(), hero typography (text-2xl px-6 py-5), palette row tints, amountColorClass
- Remove all PASTEL_COLORS arrays and InlineEditRow private functions
This commit is contained in:
@@ -3,32 +3,32 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
|
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
|
||||||
import type { BudgetDetail } from '@/lib/api'
|
import type { BudgetDetail } from '@/lib/api'
|
||||||
import { formatCurrency } from '@/lib/format'
|
import { formatCurrency } from '@/lib/format'
|
||||||
|
import { palette, headerGradient, type CategoryType } from '@/lib/palette'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
budget: BudgetDetail
|
budget: BudgetDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
const PASTEL_COLORS = ['#93c5fd', '#f9a8d4', '#fcd34d', '#a5b4fc', '#86efac', '#c4b5fd']
|
|
||||||
|
|
||||||
export function AvailableBalance({ budget }: Props) {
|
export function AvailableBalance({ budget }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { totals } = budget
|
const { totals } = budget
|
||||||
|
|
||||||
const available = totals.available
|
const available = totals.available
|
||||||
|
|
||||||
const data = [
|
const data: Array<{ name: string; value: number; categoryType: CategoryType }> = [
|
||||||
{ name: t('dashboard.remaining'), value: Math.max(0, available) },
|
{ name: t('dashboard.remaining'), value: Math.max(0, available), categoryType: 'carryover' },
|
||||||
{ name: t('dashboard.bills'), value: totals.bills_actual },
|
{ name: t('dashboard.bills'), value: totals.bills_actual, categoryType: 'bill' },
|
||||||
{ name: t('dashboard.expenses'), value: totals.expenses_actual },
|
{ name: t('dashboard.expenses'), value: totals.expenses_actual, categoryType: 'variable_expense' },
|
||||||
{ name: t('dashboard.debts'), value: totals.debts_actual },
|
{ name: t('dashboard.debts'), value: totals.debts_actual, categoryType: 'debt' },
|
||||||
{ name: t('dashboard.savings'), value: totals.savings_actual },
|
{ name: t('dashboard.savings'), value: totals.savings_actual, categoryType: 'saving' },
|
||||||
{ name: t('dashboard.investments'), value: totals.investments_actual },
|
{ name: t('dashboard.investments'), value: totals.investments_actual, categoryType: 'investment' },
|
||||||
].filter((d) => d.value > 0)
|
].filter((d) => d.value > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="bg-gradient-to-r from-sky-50 to-cyan-50">
|
<CardHeader style={headerGradient('saving')} className="px-6 py-5">
|
||||||
<CardTitle>{t('dashboard.availableAmount')}</CardTitle>
|
<CardTitle className="text-2xl font-semibold">{t('dashboard.availableAmount')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col items-center gap-4 pt-6">
|
<CardContent className="flex flex-col items-center gap-4 pt-6">
|
||||||
<div className="relative size-48">
|
<div className="relative size-48">
|
||||||
@@ -43,14 +43,17 @@ export function AvailableBalance({ budget }: Props) {
|
|||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
>
|
>
|
||||||
{data.map((_, index) => (
|
{data.map((entry, index) => (
|
||||||
<Cell key={index} fill={PASTEL_COLORS[index % PASTEL_COLORS.length]} />
|
<Cell key={index} fill={palette[entry.categoryType]?.base ?? palette.carryover.base} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
<span className="text-2xl font-bold">{formatCurrency(available, budget.currency)}</span>
|
<span className={cn('text-3xl font-bold tabular-nums', available >= 0 ? 'text-success' : 'text-destructive')}>
|
||||||
|
{formatCurrency(available, budget.currency)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('dashboard.available', 'Available')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import type { BudgetDetail } from '@/lib/api'
|
import type { BudgetDetail } from '@/lib/api'
|
||||||
import { formatCurrency } from '@/lib/format'
|
import { formatCurrency } from '@/lib/format'
|
||||||
|
import { headerGradient, amountColorClass } from '@/lib/palette'
|
||||||
|
import { InlineEditCell } from '@/components/InlineEditCell'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
budget: BudgetDetail
|
budget: BudgetDetail
|
||||||
@@ -17,7 +17,7 @@ export function BillsTracker({ budget, onUpdate }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="bg-gradient-to-r from-blue-50 to-indigo-50">
|
<CardHeader style={headerGradient('bill')}>
|
||||||
<CardTitle>{t('dashboard.billsTracker')}</CardTitle>
|
<CardTitle>{t('dashboard.billsTracker')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -31,16 +31,20 @@ export function BillsTracker({ budget, onUpdate }: Props) {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{bills.map((item) => (
|
{bills.map((item) => (
|
||||||
<InlineEditRow
|
<TableRow key={item.id}>
|
||||||
key={item.id}
|
<TableCell>{item.category_name}</TableCell>
|
||||||
label={item.category_name}
|
<TableCell className="text-right">
|
||||||
budgeted={item.budgeted_amount}
|
{formatCurrency(item.budgeted_amount, budget.currency)}
|
||||||
actual={item.actual_amount}
|
</TableCell>
|
||||||
currency={budget.currency}
|
<InlineEditCell
|
||||||
onSave={(actual) => onUpdate(item.id, { actual_amount: actual })}
|
value={item.actual_amount}
|
||||||
/>
|
currency={budget.currency}
|
||||||
|
onSave={(actual) => onUpdate(item.id, { actual_amount: actual })}
|
||||||
|
className={amountColorClass({ type: 'bill', actual: item.actual_amount, budgeted: item.budgeted_amount })}
|
||||||
|
/>
|
||||||
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
<TableRow className="border-t-2 font-bold bg-blue-50/50">
|
<TableRow className="border-t-2 font-bold">
|
||||||
<TableCell>{t('dashboard.budget')}</TableCell>
|
<TableCell>{t('dashboard.budget')}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{formatCurrency(bills.reduce((s, i) => s + i.budgeted_amount, 0), budget.currency)}
|
{formatCurrency(bills.reduce((s, i) => s + i.budgeted_amount, 0), budget.currency)}
|
||||||
@@ -55,56 +59,3 @@ export function BillsTracker({ budget, onUpdate }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineEditRow({
|
|
||||||
label,
|
|
||||||
budgeted,
|
|
||||||
actual,
|
|
||||||
currency,
|
|
||||||
onSave,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
budgeted: number
|
|
||||||
actual: number
|
|
||||||
currency: string
|
|
||||||
onSave: (value: number) => Promise<void>
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [value, setValue] = useState(String(actual))
|
|
||||||
|
|
||||||
const handleBlur = async () => {
|
|
||||||
const num = parseFloat(value)
|
|
||||||
if (!isNaN(num) && num !== actual) {
|
|
||||||
await onSave(num)
|
|
||||||
}
|
|
||||||
setEditing(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>{label}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatCurrency(budgeted, currency)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{editing ? (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
|
||||||
className="ml-auto w-28 text-right"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="cursor-pointer rounded px-2 py-1 hover:bg-muted"
|
|
||||||
onClick={() => { setValue(String(actual)); setEditing(true) }}
|
|
||||||
>
|
|
||||||
{formatCurrency(actual, currency)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import type { BudgetDetail } from '@/lib/api'
|
import type { BudgetDetail } from '@/lib/api'
|
||||||
import { formatCurrency } from '@/lib/format'
|
import { formatCurrency } from '@/lib/format'
|
||||||
|
import { headerGradient, amountColorClass } from '@/lib/palette'
|
||||||
|
import { InlineEditCell } from '@/components/InlineEditCell'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
budget: BudgetDetail
|
budget: BudgetDetail
|
||||||
@@ -19,7 +19,7 @@ export function DebtTracker({ budget, onUpdate }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="bg-gradient-to-r from-orange-50 to-red-50">
|
<CardHeader style={headerGradient('debt')}>
|
||||||
<CardTitle>{t('dashboard.debtTracker')}</CardTitle>
|
<CardTitle>{t('dashboard.debtTracker')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -33,16 +33,20 @@ export function DebtTracker({ budget, onUpdate }: Props) {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{debts.map((item) => (
|
{debts.map((item) => (
|
||||||
<InlineEditRow
|
<TableRow key={item.id}>
|
||||||
key={item.id}
|
<TableCell>{item.category_name}</TableCell>
|
||||||
label={item.category_name}
|
<TableCell className="text-right">
|
||||||
budgeted={item.budgeted_amount}
|
{formatCurrency(item.budgeted_amount, budget.currency)}
|
||||||
actual={item.actual_amount}
|
</TableCell>
|
||||||
currency={budget.currency}
|
<InlineEditCell
|
||||||
onSave={(actual) => onUpdate(item.id, { actual_amount: actual })}
|
value={item.actual_amount}
|
||||||
/>
|
currency={budget.currency}
|
||||||
|
onSave={(actual) => onUpdate(item.id, { actual_amount: actual })}
|
||||||
|
className={amountColorClass({ type: 'debt', actual: item.actual_amount, budgeted: item.budgeted_amount })}
|
||||||
|
/>
|
||||||
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
<TableRow className="border-t-2 font-bold bg-red-50/50">
|
<TableRow className="border-t-2 font-bold">
|
||||||
<TableCell>{t('dashboard.budget')}</TableCell>
|
<TableCell>{t('dashboard.budget')}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{formatCurrency(debts.reduce((s, i) => s + i.budgeted_amount, 0), budget.currency)}
|
{formatCurrency(debts.reduce((s, i) => s + i.budgeted_amount, 0), budget.currency)}
|
||||||
@@ -57,56 +61,3 @@ export function DebtTracker({ budget, onUpdate }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineEditRow({
|
|
||||||
label,
|
|
||||||
budgeted,
|
|
||||||
actual,
|
|
||||||
currency,
|
|
||||||
onSave,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
budgeted: number
|
|
||||||
actual: number
|
|
||||||
currency: string
|
|
||||||
onSave: (value: number) => Promise<void>
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [value, setValue] = useState(String(actual))
|
|
||||||
|
|
||||||
const handleBlur = async () => {
|
|
||||||
const num = parseFloat(value)
|
|
||||||
if (!isNaN(num) && num !== actual) {
|
|
||||||
await onSave(num)
|
|
||||||
}
|
|
||||||
setEditing(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>{label}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatCurrency(budgeted, currency)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{editing ? (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
|
||||||
className="ml-auto w-28 text-right"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="cursor-pointer rounded px-2 py-1 hover:bg-muted"
|
|
||||||
onClick={() => { setValue(String(actual)); setEditing(true) }}
|
|
||||||
>
|
|
||||||
{formatCurrency(actual, currency)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,24 +2,27 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
|
||||||
import type { BudgetDetail } from '@/lib/api'
|
import type { BudgetDetail } from '@/lib/api'
|
||||||
|
import { palette, headerGradient, type CategoryType } from '@/lib/palette'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
budget: BudgetDetail
|
budget: BudgetDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
const PASTEL_COLORS = ['#f9a8d4', '#fcd34d', '#93c5fd', '#a5b4fc', '#86efac', '#c4b5fd', '#fca5a5', '#fdba74']
|
|
||||||
|
|
||||||
export function ExpenseBreakdown({ budget }: Props) {
|
export function ExpenseBreakdown({ budget }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const expenses = budget.items
|
const expenses = budget.items
|
||||||
.filter((i) => i.category_type === 'variable_expense' && i.actual_amount > 0)
|
.filter((i) => i.category_type === 'variable_expense' && i.actual_amount > 0)
|
||||||
.map((i) => ({ name: i.category_name, value: i.actual_amount }))
|
.map((i) => ({
|
||||||
|
name: i.category_name,
|
||||||
|
value: i.actual_amount,
|
||||||
|
categoryType: i.category_type as CategoryType,
|
||||||
|
}))
|
||||||
|
|
||||||
if (expenses.length === 0) return null
|
if (expenses.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="bg-gradient-to-r from-pink-50 to-rose-50">
|
<CardHeader style={headerGradient('variable_expense')}>
|
||||||
<CardTitle>{t('dashboard.expenseBreakdown')}</CardTitle>
|
<CardTitle>{t('dashboard.expenseBreakdown')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
@@ -34,8 +37,8 @@ export function ExpenseBreakdown({ budget }: Props) {
|
|||||||
dataKey="value"
|
dataKey="value"
|
||||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||||
>
|
>
|
||||||
{expenses.map((_, index) => (
|
{expenses.map((entry, index) => (
|
||||||
<Cell key={index} fill={PASTEL_COLORS[index % PASTEL_COLORS.length]} />
|
<Cell key={index} fill={palette[entry.categoryType]?.base ?? palette.carryover.base} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import type { BudgetDetail } from '@/lib/api'
|
import type { BudgetDetail } from '@/lib/api'
|
||||||
import { formatCurrency } from '@/lib/format'
|
import { formatCurrency } from '@/lib/format'
|
||||||
|
import { palette, overviewHeaderGradient, amountColorClass, type CategoryType } from '@/lib/palette'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
budget: BudgetDetail
|
budget: BudgetDetail
|
||||||
@@ -12,20 +13,20 @@ export function FinancialOverview({ budget }: Props) {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { totals } = budget
|
const { totals } = budget
|
||||||
|
|
||||||
const rows = [
|
const rows: Array<{ label: string; budget: number; actual: number; categoryType: CategoryType; isIncome?: boolean }> = [
|
||||||
{ label: t('dashboard.carryover'), budget: budget.carryover_amount, actual: budget.carryover_amount, color: 'bg-sky-50' },
|
{ label: t('dashboard.carryover'), budget: budget.carryover_amount, actual: budget.carryover_amount, categoryType: 'carryover', isIncome: true },
|
||||||
{ label: t('dashboard.income'), budget: totals.income_budget, actual: totals.income_actual, color: 'bg-emerald-50' },
|
{ label: t('dashboard.income'), budget: totals.income_budget, actual: totals.income_actual, categoryType: 'income', isIncome: true },
|
||||||
{ label: t('dashboard.bills'), budget: totals.bills_budget, actual: totals.bills_actual, color: 'bg-blue-50' },
|
{ label: t('dashboard.bills'), budget: totals.bills_budget, actual: totals.bills_actual, categoryType: 'bill' },
|
||||||
{ label: t('dashboard.expenses'), budget: totals.expenses_budget, actual: totals.expenses_actual, color: 'bg-amber-50' },
|
{ label: t('dashboard.expenses'), budget: totals.expenses_budget, actual: totals.expenses_actual, categoryType: 'variable_expense' },
|
||||||
{ label: t('dashboard.debts'), budget: totals.debts_budget, actual: totals.debts_actual, color: 'bg-red-50' },
|
{ label: t('dashboard.debts'), budget: totals.debts_budget, actual: totals.debts_actual, categoryType: 'debt' },
|
||||||
{ label: t('dashboard.savings'), budget: totals.savings_budget, actual: totals.savings_actual, color: 'bg-violet-50' },
|
{ label: t('dashboard.savings'), budget: totals.savings_budget, actual: totals.savings_actual, categoryType: 'saving' },
|
||||||
{ label: t('dashboard.investments'), budget: totals.investments_budget, actual: totals.investments_actual, color: 'bg-pink-50' },
|
{ label: t('dashboard.investments'), budget: totals.investments_budget, actual: totals.investments_actual, categoryType: 'investment' },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="bg-gradient-to-r from-sky-50 to-indigo-50">
|
<CardHeader style={overviewHeaderGradient()} className="px-6 py-5">
|
||||||
<CardTitle>{t('dashboard.financialOverview')}</CardTitle>
|
<CardTitle className="text-2xl font-semibold">{t('dashboard.financialOverview')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -38,16 +39,20 @@ export function FinancialOverview({ budget }: Props) {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<TableRow key={row.label} className={row.color}>
|
<TableRow key={row.label} style={{ backgroundColor: palette[row.categoryType].light }}>
|
||||||
<TableCell className="font-medium">{row.label}</TableCell>
|
<TableCell className="font-medium">{row.label}</TableCell>
|
||||||
<TableCell className="text-right">{formatCurrency(row.budget, budget.currency)}</TableCell>
|
<TableCell className="text-right">{formatCurrency(row.budget, budget.currency)}</TableCell>
|
||||||
<TableCell className="text-right">{formatCurrency(row.actual, budget.currency)}</TableCell>
|
<TableCell className={`text-right ${amountColorClass({ type: row.categoryType, actual: row.actual, budgeted: row.budget, isIncome: row.isIncome })}`}>
|
||||||
|
{formatCurrency(row.actual, budget.currency)}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
<TableRow className="border-t-2 font-bold">
|
<TableRow className="border-t-2 font-bold">
|
||||||
<TableCell>{t('dashboard.remaining')}</TableCell>
|
<TableCell>{t('dashboard.remaining')}</TableCell>
|
||||||
<TableCell />
|
<TableCell />
|
||||||
<TableCell className="text-right">{formatCurrency(totals.available, budget.currency)}</TableCell>
|
<TableCell className={`text-right ${amountColorClass({ type: 'carryover', actual: totals.available, budgeted: 0, isAvailable: true })}`}>
|
||||||
|
{formatCurrency(totals.available, budget.currency)}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||||
import type { BudgetDetail } from '@/lib/api'
|
import type { BudgetDetail } from '@/lib/api'
|
||||||
import { formatCurrency } from '@/lib/format'
|
import { formatCurrency } from '@/lib/format'
|
||||||
|
import { headerGradient, amountColorClass, palette } from '@/lib/palette'
|
||||||
|
import { InlineEditCell } from '@/components/InlineEditCell'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
budget: BudgetDetail
|
budget: BudgetDetail
|
||||||
@@ -24,7 +24,7 @@ export function VariableExpenses({ budget, onUpdate }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="bg-gradient-to-r from-amber-50 to-yellow-50">
|
<CardHeader style={headerGradient('variable_expense')}>
|
||||||
<CardTitle>{t('dashboard.variableExpenses')}</CardTitle>
|
<CardTitle>{t('dashboard.variableExpenses')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-6 pt-4">
|
<CardContent className="flex flex-col gap-6 pt-4">
|
||||||
@@ -38,17 +38,27 @@ export function VariableExpenses({ budget, onUpdate }: Props) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{expenses.map((item) => (
|
{expenses.map((item) => {
|
||||||
<InlineEditRow
|
const remaining = item.budgeted_amount - item.actual_amount
|
||||||
key={item.id}
|
return (
|
||||||
label={item.category_name}
|
<TableRow key={item.id}>
|
||||||
budgeted={item.budgeted_amount}
|
<TableCell>{item.category_name}</TableCell>
|
||||||
actual={item.actual_amount}
|
<TableCell className="text-right">
|
||||||
currency={budget.currency}
|
{formatCurrency(item.budgeted_amount, budget.currency)}
|
||||||
onSave={(actual) => onUpdate(item.id, { actual_amount: actual })}
|
</TableCell>
|
||||||
/>
|
<InlineEditCell
|
||||||
))}
|
value={item.actual_amount}
|
||||||
<TableRow className="border-t-2 font-bold bg-amber-50/50">
|
currency={budget.currency}
|
||||||
|
onSave={(actual) => onUpdate(item.id, { actual_amount: actual })}
|
||||||
|
className={amountColorClass({ type: 'variable_expense', actual: item.actual_amount, budgeted: item.budgeted_amount })}
|
||||||
|
/>
|
||||||
|
<TableCell className={`text-right ${remaining < 0 ? 'text-destructive' : ''}`}>
|
||||||
|
{formatCurrency(remaining, budget.currency)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<TableRow className="border-t-2 font-bold">
|
||||||
<TableCell>{t('dashboard.budget')}</TableCell>
|
<TableCell>{t('dashboard.budget')}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{formatCurrency(expenses.reduce((s, i) => s + i.budgeted_amount, 0), budget.currency)}
|
{formatCurrency(expenses.reduce((s, i) => s + i.budgeted_amount, 0), budget.currency)}
|
||||||
@@ -72,8 +82,8 @@ export function VariableExpenses({ budget, onUpdate }: Props) {
|
|||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar dataKey={t('dashboard.budget')} fill="#fcd34d" radius={[4, 4, 0, 0]} />
|
<Bar dataKey={t('dashboard.budget')} fill={palette.variable_expense.light} radius={[4, 4, 0, 0]} />
|
||||||
<Bar dataKey={t('dashboard.actual')} fill="#f9a8d4" radius={[4, 4, 0, 0]} />
|
<Bar dataKey={t('dashboard.actual')} fill={palette.variable_expense.base} radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,61 +92,3 @@ export function VariableExpenses({ budget, onUpdate }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineEditRow({
|
|
||||||
label,
|
|
||||||
budgeted,
|
|
||||||
actual,
|
|
||||||
currency,
|
|
||||||
onSave,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
budgeted: number
|
|
||||||
actual: number
|
|
||||||
currency: string
|
|
||||||
onSave: (value: number) => Promise<void>
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [value, setValue] = useState(String(actual))
|
|
||||||
|
|
||||||
const handleBlur = async () => {
|
|
||||||
const num = parseFloat(value)
|
|
||||||
if (!isNaN(num) && num !== actual) {
|
|
||||||
await onSave(num)
|
|
||||||
}
|
|
||||||
setEditing(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = budgeted - actual
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>{label}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatCurrency(budgeted, currency)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{editing ? (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
|
||||||
className="ml-auto w-28 text-right"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="cursor-pointer rounded px-2 py-1 hover:bg-muted"
|
|
||||||
onClick={() => { setValue(String(actual)); setEditing(true) }}
|
|
||||||
>
|
|
||||||
{formatCurrency(actual, currency)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className={`text-right ${remaining < 0 ? 'text-destructive' : ''}`}>
|
|
||||||
{formatCurrency(remaining, currency)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user