2026-05-27 21:34:12 +03:30
|
|
|
"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";
|
2026-06-11 23:10:38 +03:30
|
|
|
import { JalaliDateField } from "@/components/ui/jalali-date-field";
|
2026-05-27 21:34:12 +03:30
|
|
|
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">
|
2026-06-11 23:10:38 +03:30
|
|
|
<JalaliDateField id="exp-from" className="w-40" value={from} onChange={setFrom} />
|
2026-05-27 21:34:12 +03:30
|
|
|
</LabeledField>
|
|
|
|
|
<LabeledField label={t("toDate")} htmlFor="exp-to">
|
2026-06-11 23:10:38 +03:30
|
|
|
<JalaliDateField id="exp-to" className="w-40" value={to} onChange={setTo} />
|
2026-05-27 21:34:12 +03:30
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|