feat(dashboard): Next.js 16 merchant panel with offline POS and PWA
Complete merchant dashboard upgrade:
Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors
Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect
PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
"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 { 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">
|
||||
<Input
|
||||
id="exp-from"
|
||||
type="date"
|
||||
dir="ltr"
|
||||
className="w-40 text-end"
|
||||
value={from}
|
||||
max={to}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("toDate")} htmlFor="exp-to">
|
||||
<Input
|
||||
id="exp-to"
|
||||
type="date"
|
||||
dir="ltr"
|
||||
className="w-40 text-end"
|
||||
value={to}
|
||||
min={from}
|
||||
max={today}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user