Files

805 lines
30 KiB
TypeScript

"use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { LocalizedFields, type LocalizedFieldLocale, type LocalizedFieldValues } from "@/components/i18n/localized-fields";
import { contentTranslationRegistry } from "@/lib/i18n/content";
import {
createFinanceTransactionRecord,
deleteFinanceTransactionRecord,
updateFinanceTransactionRecord,
} from "@/app/(dashboard)/finance/actions";
import { Badge, Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
toast,
} from "poyraz-ui/molecules";
import {
ArrowDownRight,
ArrowUpRight,
ChevronLeft,
ChevronRight,
Pencil,
Plus,
Trash2,
Wallet,
Brain,
Loader2,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { StatCard } from "@/components/system/stat-card";
export type FinanceRelationOption = {
id: string;
name: string;
client_id?: string | null;
};
export type FinanceTransactionItem = {
id: string;
type: "income" | "expense";
amount: number;
currency: string;
transaction_date: string;
category: string | null;
payment_status: "planned" | "pending" | "paid" | "cancelled";
client_id: string | null;
project_id: string | null;
clientName: string | null;
projectName: string | null;
description: string | null;
translations?: LocalizedFieldValues;
};
const paymentStatusClasses = {
planned: "border-blue-200 bg-blue-50 text-blue-700",
pending: "border-amber-200 bg-amber-50 text-amber-700",
paid: "border-emerald-200 bg-emerald-50 text-emerald-700",
cancelled: "border-zinc-200 bg-zinc-50 text-zinc-700",
};
const currencyOptions = [
{ value: "USD", labelKey: "finance.currency.usd" },
{ value: "EUR", labelKey: "finance.currency.eur" },
{ value: "TRY", labelKey: "finance.currency.try" },
{ value: "GBP", labelKey: "finance.currency.gbp" },
{ value: "CAD", labelKey: "finance.currency.cad" },
{ value: "AUD", labelKey: "finance.currency.aud" },
];
const financeSummaryCardConfig = [
{ key: "afterTax", tone: "green", icon: Wallet, featured: true },
{ key: "net", tone: "primary", icon: Wallet, featured: true },
{ key: "income", tone: "green", icon: ArrowUpRight, featured: false },
{ key: "expense", tone: "rose", icon: ArrowDownRight, featured: false },
{ key: "pending", tone: "amber", icon: Wallet, featured: false },
{ key: "tax", tone: "amber", icon: Wallet, featured: false },
] as const;
type FinanceClientProps = {
transactions: FinanceTransactionItem[];
clients: FinanceRelationOption[];
projects: FinanceRelationOption[];
localization: {
defaultLocale: string;
locales: LocalizedFieldLocale[];
};
};
export function FinanceClient({ transactions, clients, projects, localization }: FinanceClientProps) {
const t = useTranslations();
const [query, setQuery] = useState("");
const [monthFilter, setMonthFilter] = useState(() => new Date().toISOString().slice(0, 7));
const summaryTrackRef = useRef<HTMLDivElement>(null);
const normalizedQuery = query.trim().toLowerCase();
const filteredByMonth = transactions.filter((transaction) =>
transaction.transaction_date.startsWith(monthFilter),
);
const filteredTransactions = normalizedQuery
? filteredByMonth.filter((transaction) =>
[
transaction.description,
transaction.category,
transaction.clientName,
transaction.projectName,
t(`finance.types.${transaction.type}`),
]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(normalizedQuery)),
)
: filteredByMonth;
const summary = useMemo(() => calculateSummary(filteredByMonth), [filteredByMonth]);
const categoryBreakdown = useMemo(() => calculateExpenseCategories(filteredByMonth), [filteredByMonth]);
const summaryCards = financeSummaryCardConfig.map((card) => ({
...card,
label: t(`finance.summary.${card.key}`),
value: formatCurrency(summary[card.key]),
}));
const scrollSummary = (direction: -1 | 1) => {
const track = summaryTrackRef.current;
if (!track) return;
track.scrollBy({
left: direction * Math.max(track.clientWidth * 0.72, 260),
behavior: "smooth",
});
};
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6">
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
{t("finance.title")}
</h1>
</div>
<div className="flex gap-2">
<AIFinanceDialog />
<FinanceDialog mode="create" clients={clients} projects={projects} localization={localization} />
</div>
</div>
<section aria-labelledby="finance-summary-title" className="space-y-3">
<div className="flex items-end justify-between gap-4">
<div>
<h2 id="finance-summary-title" className="text-base font-semibold text-foreground">
{t("finance.summary.title")}
</h2>
<p className="text-sm text-muted-foreground">
{t("finance.summary.description")}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
effect="shine"
type="button"
variant="secondary"
size="icon"
aria-label={t("finance.summary.previous")}
onClick={() => scrollSummary(-1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
effect="shine"
type="button"
variant="secondary"
size="icon"
aria-label={t("finance.summary.next")}
onClick={() => scrollSummary(1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div
ref={summaryTrackRef}
role="region"
aria-label={t("finance.summary.region")}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollSummary(-1);
}
if (event.key === "ArrowRight") {
event.preventDefault();
scrollSummary(1);
}
}}
className="flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-smooth pb-3 [scrollbar-width:none] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-scrollbar]:hidden"
>
{summaryCards.map((card) => (
<StatCard
key={card.key}
label={card.label}
value={card.value}
icon={card.icon}
tone={card.tone}
featured={card.featured}
className="w-[250px] shrink-0 snap-start"
/>
))}
</div>
</section>
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<Card>
<CardContent className="space-y-4 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">{t("finance.list.title")}</h2>
<p className="text-sm text-muted-foreground">
{t("finance.list.description", { count: filteredTransactions.length })}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("finance.list.searchPlaceholder")}
className="sm:w-80"
/>
<Input
type="month"
value={monthFilter}
onChange={(event) => setMonthFilter(event.target.value)}
className="sm:w-44"
/>
</div>
</div>
{filteredTransactions.length > 0 ? (
<div className="overflow-x-auto rounded-sm border border-border">
<div className="min-w-[800px]">
<div className="grid grid-cols-[1.4fr_0.8fr_0.8fr_0.8fr_1fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground">
<span>{t("finance.list.headers.transaction")}</span>
<span>{t("finance.list.headers.date")}</span>
<span>{t("finance.list.headers.amount")}</span>
<span>{t("finance.list.headers.status")}</span>
<span className="text-right">{t("finance.list.headers.action")}</span>
</div>
<div className="divide-y divide-border">
{filteredTransactions.map((transaction) => (
<TransactionRow
key={transaction.id}
transaction={transaction}
clients={clients}
projects={projects}
localization={localization}
/>
))}
</div>
</div>
</div>
) : (
<EmptyState hasQuery={Boolean(normalizedQuery)} />
)}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4 p-4">
<div>
<h2 className="text-base font-semibold text-foreground">{t("finance.expenseCategories.title")}</h2>
<p className="text-sm text-muted-foreground">{t("finance.expenseCategories.description")}</p>
</div>
{categoryBreakdown.length > 0 ? (
<div className="space-y-3">
{categoryBreakdown.map((item) => (
<div key={item.category} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{item.category === "__uncategorized"
? t("finance.expenseCategories.noCategory")
: item.category}
</span>
<span className="font-medium text-foreground">{formatCurrency(item.amount)}</span>
</div>
<div className="h-2 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${item.percent}%` }}
/>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">{t("finance.expenseCategories.noExpense")}</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function TransactionRow({
transaction,
clients,
projects,
localization,
}: {
transaction: FinanceTransactionItem;
clients: FinanceRelationOption[];
projects: FinanceRelationOption[];
localization: {
defaultLocale: string;
locales: LocalizedFieldLocale[];
};
}) {
const t = useTranslations();
const isIncome = transaction.type === "income";
return (
<div className="grid gap-4 px-4 py-4 grid-cols-[1.4fr_0.8fr_0.8fr_0.8fr_1fr] items-center">
<div className="flex min-w-0 items-start gap-3">
<div className={isIncome ? "rounded-sm bg-emerald-50 p-2 text-emerald-700" : "rounded-sm bg-rose-50 p-2 text-rose-700"}>
{isIncome ? <ArrowUpRight className="h-4 w-4" /> : <ArrowDownRight className="h-4 w-4" />}
</div>
<div className="min-w-0">
<div className="font-medium text-foreground">
{transaction.description || t(`finance.types.${transaction.type}`)}
</div>
<div className="truncate text-sm text-muted-foreground">
{transaction.category || t("finance.expenseCategories.noCategory")} · {transaction.projectName || transaction.clientName || t("finance.form.noClient")}
</div>
</div>
</div>
<div className="text-sm text-muted-foreground">{formatDate(transaction.transaction_date)}</div>
<div className={isIncome ? "font-semibold text-emerald-700" : "font-semibold text-rose-700"}>
{isIncome ? "+" : "-"}
{formatCurrency(transaction.amount, transaction.currency)}
</div>
<div>
<Badge className={paymentStatusClasses[transaction.payment_status]}>
{t(`finance.paymentStatus.${transaction.payment_status}`)}
</Badge>
</div>
<div className="flex justify-end gap-2">
<FinanceDialog mode="edit" transaction={transaction} clients={clients} projects={projects} localization={localization} />
<form action={deleteFinanceTransactionRecord}>
<input type="hidden" name="id" value={transaction.id} />
<Button effect="shine" type="submit" variant="secondary" className="gap-2 text-rose-600">
<Trash2 className="h-4 w-4" />
{t("finance.actions.delete")}
</Button>
</form>
</div>
</div>
);
}
function FinanceDialog({
mode,
transaction,
clients,
projects,
localization,
}: {
mode: "create" | "edit";
transaction?: FinanceTransactionItem;
clients: FinanceRelationOption[];
projects: FinanceRelationOption[];
localization: {
defaultLocale: string;
locales: LocalizedFieldLocale[];
};
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const action = mode === "create" ? createFinanceTransactionRecord : updateFinanceTransactionRecord;
async function handleSubmit(formData: FormData) {
setIsSubmitting(true);
try {
await action(formData);
setOpen(false);
toast.success(mode === "create" ? t("finance.form.messages.createSuccess") : t("finance.form.messages.updateSuccess"));
} catch (error) {
toast.error(resolveTranslatedError(t, error, "finance.form.messages.error"));
} finally {
setIsSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button effect="shine" variant={mode === "create" ? "default" : "secondary"} className="gap-2">
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{mode === "create" ? t("finance.actions.add") : t("finance.actions.edit")}
</Button>
</DialogTrigger>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] flex-col overflow-hidden p-0 sm:max-h-[min(680px,calc(100dvh-4rem))] sm:max-w-xl data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95">
<form action={handleSubmit} className="flex min-h-0 flex-1 flex-col overflow-hidden">
{transaction ? <input type="hidden" name="id" value={transaction.id} /> : null}
<DialogHeader className="shrink-0 px-5 pb-4 pt-5 pr-12">
<DialogTitle>{mode === "create" ? t("finance.form.createTitle") : t("finance.form.editTitle")}</DialogTitle>
<DialogDescription>{t("finance.form.description")}</DialogDescription>
</DialogHeader>
<div className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto px-5 pb-5">
<FinanceFormFields transaction={transaction} clients={clients} projects={projects} localization={localization} />
</div>
<DialogFooter className="shrink-0 border-t border-border bg-background p-5">
<Button variant="default" effect="shine" type="submit" disabled={isSubmitting} className="w-full gap-2 sm:w-auto">
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{isSubmitting ? t("finance.actions.saving") : mode === "create" ? t("finance.form.submitCreate") : t("finance.form.submitEdit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function FinanceFormFields({
transaction,
clients,
projects,
localization,
}: {
transaction?: FinanceTransactionItem;
clients: FinanceRelationOption[];
projects: FinanceRelationOption[];
localization: {
defaultLocale: string;
locales: LocalizedFieldLocale[];
};
}) {
const t = useTranslations();
const [clientId, setClientId] = useState(transaction?.client_id || "__none");
const [projectId, setProjectId] = useState(transaction?.project_id || "__none");
const selectedProject =
projectId === "__none" ? null : projects.find((project) => project.id === projectId) || null;
const shouldLockClient = Boolean(selectedProject);
const filteredProjects =
clientId === "__none" || shouldLockClient
? projects
: projects.filter((project) => project.client_id === clientId);
const currencyValue = transaction?.currency || "USD";
const hasCustomCurrency = !currencyOptions.some((currency) => currency.value === currencyValue);
function handleClientChange(nextClientId: string) {
setClientId(nextClientId);
if (
projectId !== "__none" &&
nextClientId !== "__none" &&
!projects.some((project) => project.id === projectId && project.client_id === nextClientId)
) {
setProjectId("__none");
}
}
function handleProjectChange(nextProjectId: string) {
setProjectId(nextProjectId);
if (nextProjectId === "__none") {
return;
}
const nextProject = projects.find((project) => project.id === nextProjectId);
setClientId(nextProject?.client_id || "__none");
}
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<SelectField name="type" label={t("finance.form.type")} defaultValue={transaction?.type || "income"}>
<SelectItem value="income">{t("finance.types.income")}</SelectItem>
<SelectItem value="expense">{t("finance.types.expense")}</SelectItem>
</SelectField>
<SelectField name="payment_status" label={t("finance.form.paymentStatus")} defaultValue={transaction?.payment_status || "planned"}>
<SelectItem value="planned">{t("finance.paymentStatus.planned")}</SelectItem>
<SelectItem value="pending">{t("finance.paymentStatus.pending")}</SelectItem>
<SelectItem value="paid">{t("finance.paymentStatus.paid")}</SelectItem>
<SelectItem value="cancelled">{t("finance.paymentStatus.cancelled")}</SelectItem>
</SelectField>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>{t("finance.form.amount")}</Label>
<Input name="amount" type="number" min="0" step="0.01" required defaultValue={transaction?.amount ?? ""} />
</div>
<div className="grid gap-2">
<Label>{t("finance.form.currency")}</Label>
<Select name="currency" defaultValue={currencyValue}>
<SelectTrigger>
<SelectValue placeholder={t("finance.form.currencySelect")} />
</SelectTrigger>
<SelectContent>
{currencyOptions.map((currency) => (
<SelectItem key={currency.value} value={currency.value}>
{t(currency.labelKey)}
</SelectItem>
))}
{hasCustomCurrency ? (
<SelectItem value={currencyValue}>{currencyValue}</SelectItem>
) : null}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>{t("finance.form.date")}</Label>
<Input name="transaction_date" type="date" defaultValue={transaction?.transaction_date || new Date().toISOString().slice(0, 10)} />
</div>
</div>
<LocalizedFields
idPrefix={`finance-${transaction?.id || "new"}-cat`}
defaultLocale={localization.defaultLocale}
locales={localization.locales}
fields={contentTranslationRegistry.finance_transaction
.filter((f) => f.name === "category")
.map((f) => ({
...f,
label: t(`finance.fields.${f.name}`) || f.label,
placeholder: "placeholder" in f && typeof f.placeholder === "string"
? t(`finance.placeholders.${f.name}`) || f.placeholder
: undefined,
}))}
values={transaction?.translations}
fallbackValues={{
category: transaction?.category,
}}
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>{t("finance.form.client")}</Label>
{shouldLockClient ? <input type="hidden" name="client_id" value={clientId} /> : null}
<Select
name="client_id"
value={clientId}
onValueChange={handleClientChange}
disabled={shouldLockClient}
>
<SelectTrigger>
<SelectValue placeholder={t("finance.form.clientSelect")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t("finance.form.noClient")}</SelectItem>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>{t("finance.form.project")}</Label>
<Select name="project_id" value={projectId} onValueChange={handleProjectChange}>
<SelectTrigger>
<SelectValue placeholder={t("finance.form.projectSelect")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t("finance.form.noProject")}</SelectItem>
{filteredProjects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<LocalizedFields
idPrefix={`finance-${transaction?.id || "new"}-desc`}
defaultLocale={localization.defaultLocale}
locales={localization.locales}
fields={contentTranslationRegistry.finance_transaction
.filter((f) => f.name === "description")
.map((f) => ({
...f,
label: t(`finance.fields.${f.name}`) || f.label,
placeholder: "placeholder" in f && typeof f.placeholder === "string"
? t(`finance.placeholders.${f.name}`) || f.placeholder
: undefined,
}))}
values={transaction?.translations}
fallbackValues={{
description: transaction?.description,
}}
/>
</div>
);
}
function SelectField({ name, label, defaultValue, children }: { name: string; label: string; defaultValue: string; children: React.ReactNode }) {
return (
<div className="grid gap-2">
<Label>{label}</Label>
<Select name={name} defaultValue={defaultValue}>
<SelectTrigger><SelectValue placeholder={label} /></SelectTrigger>
<SelectContent>{children}</SelectContent>
</Select>
</div>
);
}
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
const t = useTranslations();
return (
<div className="flex min-h-72 flex-col items-center justify-center rounded-sm border border-dashed border-border bg-muted/20 p-8 text-center">
<Wallet className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold text-foreground">
{hasQuery ? t("finance.empty.noMatchTitle") : t("finance.empty.noTransactionTitle")}
</h3>
<p className="mt-2 max-w-md text-sm text-muted-foreground">
{hasQuery
? t("finance.empty.noMatchDesc")
: t("finance.empty.noTransactionDesc")}
</p>
</div>
);
}
function calculateSummary(transactions: FinanceTransactionItem[]) {
return transactions.reduce(
(summary, transaction) => {
if (transaction.type === "income" && transaction.payment_status === "paid") {
summary.income += transaction.amount;
}
if (transaction.type === "expense" && transaction.payment_status === "paid") {
summary.expense += transaction.amount;
}
if (transaction.payment_status === "pending" || transaction.payment_status === "planned") {
summary.pending += transaction.amount;
}
summary.net = summary.income - summary.expense;
summary.tax = summary.income * 0.20; // 20% KDV/Vergi tahmini
summary.afterTax = summary.net - summary.tax;
return summary;
},
{ income: 0, expense: 0, net: 0, tax: 0, afterTax: 0, pending: 0 },
);
}
function formatMessageContent(text: string) {
if (!text) return null;
const lines = text.split("\n");
return lines.map((line, i) => (
<span key={i}>
{line.split(/(\*\*.*?\*\*|\*.*?\*)/g).map((part, j) => {
if (part.startsWith("**") && part.endsWith("**")) {
return (
<strong key={j} className="font-semibold text-foreground">
{part.slice(2, -2)}
</strong>
);
}
if (part.startsWith("*") && part.endsWith("*")) {
return <em key={j}>{part.slice(1, -1)}</em>;
}
return <span key={j}>{part}</span>;
})}
{i !== lines.length - 1 && <br />}
</span>
));
}
function AIFinanceDialog() {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<string | null>(null);
const handleAnalyze = async () => {
setLoading(true);
setResult(null);
try {
const res = await fetch("/api/finance-analysis", { method: "POST" });
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || t("finance.ai.error"));
}
setResult(data.text);
} catch (error) {
setResult(
t("finance.ai.errorWithReason", {
reason: resolveTranslatedError(t, error, "finance.ai.error"),
}),
);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button effect="shine" variant="secondary" className="gap-2">
<Brain className="h-4 w-4" />
{t("finance.actions.aiAnalysis")}
</Button>
</DialogTrigger>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-2xl max-h-[80vh] overflow-y-auto rounded-lg p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Brain className="h-5 w-5 text-indigo-600" />
{t("finance.ai.title")}
</DialogTitle>
<DialogDescription>
{t("finance.ai.description")}
</DialogDescription>
</DialogHeader>
<div className="py-4">
{!result && !loading && (
<div className="text-center py-10">
<Button variant="default" effect="shine" onClick={handleAnalyze} className="gap-2">
<Brain className="h-4 w-4" />
{t("finance.ai.generate")}
</Button>
</div>
)}
{loading && (
<div className="flex flex-col items-center justify-center py-10 space-y-4 text-indigo-600">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm font-medium">{t("finance.ai.analyzing")}</p>
</div>
)}
{result && (
<div className="bg-muted/50 border border-border rounded-lg p-5 text-sm prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap">
{formatMessageContent(result)}
</div>
)}
</div>
{result && (
<DialogFooter className="gap-2 sm:gap-0">
<Button effect="shine" variant="secondary" onClick={() => setOpen(false)}>{t("finance.ai.close")}</Button>
<Button effect="shine" variant="default" onClick={handleAnalyze} className="gap-2">
<Brain className="h-4 w-4" />
{t("finance.ai.regenerate")}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
function calculateExpenseCategories(transactions: FinanceTransactionItem[]) {
const totals = new Map<string, number>();
for (const transaction of transactions) {
if (transaction.type !== "expense") continue;
const category = transaction.category || "__uncategorized";
totals.set(category, (totals.get(category) || 0) + transaction.amount);
}
const total = Array.from(totals.values()).reduce((sum, value) => sum + value, 0);
return Array.from(totals.entries())
.map(([category, amount]) => ({
category,
amount,
percent: total ? Math.round((amount / total) * 100) : 0,
}))
.sort((a, b) => b.amount - a.amount)
.slice(0, 6);
}
function resolveTranslatedError(
t: ReturnType<typeof useTranslations>,
error: unknown,
fallbackKey: string,
) {
if (!(error instanceof Error)) return t(fallbackKey);
if (/^(finance|api|validation)\./.test(error.message)) return t(error.message);
return error.message || t(fallbackKey);
}
function formatCurrency(value: number, currency = "USD") {
return new Intl.NumberFormat(getDocumentIntlLocale(), {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(value);
}
function formatDate(value: string) {
return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(`${value}T00:00:00`));
}