Files
meezi/web/dashboard/src/components/expenses/expenses-screen.tsx
T

343 lines
12 KiB
TypeScript
Raw Normal View History

"use client";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocale, useTranslations } from "next-intl";
import { Plus, Trash2 } from "lucide-react";
import { apiDelete, apiGet, apiGetPaged, apiPost } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatCurrency, formatNumber } from "@/lib/format";
import { isoTodayTehran } from "@/lib/reports/analytics";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { JalaliDateField } from "@/components/ui/jalali-date-field";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type Branch = { id: string; name: string };
type ShiftDto = {
id: string;
branchId: string;
status: string;
};
export type ExpenseCategory =
| "Supplies"
| "Utilities"
| "Salary"
| "Rent"
| "Maintenance"
| "Other";
export type ExpenseRow = {
id: string;
cafeId: string;
branchId: string;
shiftId?: string | null;
category: ExpenseCategory;
amount: number;
note?: string | null;
receiptImageUrl?: string | null;
createdByUserId: string;
createdAt: string;
};
const CATEGORIES: ExpenseCategory[] = [
"Supplies",
"Utilities",
"Salary",
"Rent",
"Maintenance",
"Other",
];
const MANAGER_ROLES = new Set(["Owner", "Manager"]);
export function ExpensesScreen() {
const t = useTranslations("expenses");
const tCommon = useTranslations("common");
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role ?? "");
const queryClient = useQueryClient();
const today = isoTodayTehran();
const [branchId, setBranchId] = useState<string>("");
const [from, setFrom] = useState(today);
const [to, setTo] = useState(today);
const [showModal, setShowModal] = useState(false);
const [category, setCategory] = useState<ExpenseCategory>("Supplies");
const [amount, setAmount] = useState("");
const [note, setNote] = useState("");
const [linkShift, setLinkShift] = useState(true);
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
useEffect(() => {
if (!branchId && branches.length > 0) setBranchId(branches[0]!.id);
}, [branchId, branches]);
const { data: currentShift } = useQuery({
queryKey: ["shift-current", cafeId, branchId],
queryFn: async () => {
try {
return await apiGet<ShiftDto>(
`/api/cafes/${cafeId}/branches/${branchId}/shifts/current`
);
} catch {
return null;
}
},
enabled: !!cafeId && !!branchId,
});
const listKey = ["expenses", cafeId, branchId, from, to] as const;
const { data: listResponse, isLoading } = useQuery({
queryKey: listKey,
queryFn: () =>
apiGetPaged<ExpenseRow>(
`/api/cafes/${cafeId}/expenses?branchId=${encodeURIComponent(branchId)}&from=${from}&to=${to}&page=1&pageSize=50`
),
enabled: !!cafeId && !!branchId && !!from && !!to,
});
const rows = useMemo(() => listResponse?.items ?? [], [listResponse?.items]);
const totalAmount = useMemo(() => rows.reduce((s, r) => s + r.amount, 0), [rows]);
const createExpense = useMutation({
mutationFn: () =>
apiPost<ExpenseRow>(`/api/cafes/${cafeId}/expenses`, {
branchId,
shiftId: linkShift && currentShift ? currentShift.id : null,
category,
amount: Number(amount),
note: note.trim() || null,
receiptImageUrl: null,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: listKey });
setShowModal(false);
setAmount("");
setNote("");
setCategory("Supplies");
},
});
const deleteExpense = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/expenses/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: listKey }),
});
const canDelete = MANAGER_ROLES.has(role);
if (!cafeId) return null;
return (
<div className="space-y-6 bg-[#f5f5f4] min-h-full -m-4 p-4 md:-m-6 md:p-6">
<PageHeader
title={t("title")}
subtitle={t("subtitle")}
action={
<Button
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
onClick={() => setShowModal(true)}
disabled={!branchId}
>
<Plus className="ms-2 h-4 w-4" />
{t("addExpense")}
</Button>
}
/>
<Card className="rounded-xl border border-border/80 bg-card">
<CardContent className="flex flex-wrap items-end gap-4 pt-6">
<LabeledField label={t("branch")} htmlFor="exp-branch">
<select
id="exp-branch"
className="h-9 min-w-[10rem] rounded-md border border-input bg-background px-3 text-sm"
value={branchId}
onChange={(e) => setBranchId(e.target.value)}
>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</LabeledField>
<LabeledField label={t("fromDate")} htmlFor="exp-from">
<JalaliDateField id="exp-from" className="w-40" value={from} onChange={setFrom} />
</LabeledField>
<LabeledField label={t("toDate")} htmlFor="exp-to">
<JalaliDateField id="exp-to" className="w-40" value={to} onChange={setTo} />
</LabeledField>
<div className="ms-auto text-end">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("periodTotal")}
</p>
<p className="text-lg font-semibold text-[#BA7517]">
{formatCurrency(totalAmount, numberLocale)}
</p>
</div>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 bg-card">
<CardHeader>
<CardTitle className="text-base">{t("listTitle")}</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full min-w-[28rem] text-sm">
<thead>
<tr className="border-b text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
<th className="py-2 text-start">{t("colDate")}</th>
<th className="py-2 text-start">{t("colCategory")}</th>
<th className="py-2 text-start">{t("colNote")}</th>
<th className="py-2 text-end">{t("colAmount")}</th>
{canDelete ? <th className="py-2 w-10" /> : null}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={canDelete ? 5 : 4} className="py-4 text-muted-foreground">
{t("loading")}
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={canDelete ? 5 : 4} className="py-4 text-muted-foreground">
{t("empty")}
</td>
</tr>
) : (
rows.map((row) => (
<tr key={row.id} className="border-b border-border/50">
<td className="py-2.5 tabular-nums text-muted-foreground" dir="ltr">
{new Date(row.createdAt).toLocaleString(
locale === "en" ? "en-GB" : "fa-IR",
{ dateStyle: "short", timeStyle: "short" }
)}
</td>
<td className="py-2.5">{t(`categories.${row.category}`)}</td>
<td className="py-2.5 max-w-[12rem] truncate text-muted-foreground">
{row.note ?? "—"}
</td>
<td className="py-2.5 text-end font-medium text-[#BA7517] tabular-nums">
{formatCurrency(row.amount, numberLocale)}
</td>
{canDelete ? (
<td className="py-2.5 text-end">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[#A32D2D]"
onClick={() => deleteExpense.mutate(row.id)}
disabled={deleteExpense.isPending}
aria-label={tCommon("delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
) : null}
</tr>
))
)}
</tbody>
</table>
{listResponse?.meta ? (
<p className="mt-3 text-xs text-muted-foreground">
{t("rowCount", {
count: formatNumber(listResponse.meta.total, numberLocale),
})}
</p>
) : null}
</CardContent>
</Card>
{showModal ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="expense-modal-title"
>
<Card className="w-full max-w-md rounded-xl border border-border/80 bg-card shadow-lg">
<CardHeader>
<CardTitle id="expense-modal-title" className="text-base">
{t("addExpense")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<LabeledField label={t("category")} htmlFor="exp-cat">
<select
id="exp-cat"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={category}
onChange={(e) => setCategory(e.target.value as ExpenseCategory)}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{t(`categories.${c}`)}
</option>
))}
</select>
</LabeledField>
<LabeledField label={t("amount")} htmlFor="exp-amount">
<Input
id="exp-amount"
type="number"
min={1}
dir="ltr"
className="text-end"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</LabeledField>
<LabeledField label={t("note")} htmlFor="exp-note">
<Input
id="exp-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("notePlaceholder")}
/>
</LabeledField>
{currentShift ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={linkShift}
onChange={(e) => setLinkShift(e.target.checked)}
/>
{t("linkOpenShift")}
</label>
) : (
<p className="text-xs text-muted-foreground">{t("noOpenShift")}</p>
)}
<div className="flex gap-2 pt-2">
<Button
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
disabled={!amount || createExpense.isPending}
onClick={() => createExpense.mutate()}
>
{tCommon("confirm")}
</Button>
<Button variant="outline" onClick={() => setShowModal(false)}>
{tCommon("cancel")}
</Button>
</div>
</CardContent>
</Card>
</div>
) : null}
</div>
);
}