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,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Receipt, Trash2 } from "lucide-react";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
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 } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useConfirm } from "@/components/providers/confirm-provider";
|
||||
|
||||
interface TaxRow {
|
||||
id: string;
|
||||
name: string;
|
||||
rate: number;
|
||||
isDefault: boolean;
|
||||
isRequired: boolean;
|
||||
isCompound: boolean;
|
||||
}
|
||||
|
||||
export function TaxesScreen() {
|
||||
const t = useTranslations("taxes");
|
||||
const tCommon = useTranslations("common");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const canEdit = isCafeOwner(role);
|
||||
const queryClient = useQueryClient();
|
||||
const confirmDialog = useConfirm();
|
||||
const [name, setName] = useState("");
|
||||
const [rate, setRate] = useState("9");
|
||||
|
||||
const { data: taxes = [], isLoading } = useQuery({
|
||||
queryKey: ["taxes", cafeId],
|
||||
queryFn: () => apiGet<TaxRow[]>(`/api/cafes/${cafeId}/taxes`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const addTax = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/cafes/${cafeId}/taxes`, {
|
||||
name,
|
||||
rate: parseFloat(rate),
|
||||
isDefault: taxes.length === 0,
|
||||
isRequired: true,
|
||||
isCompound: false,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
queryClient.invalidateQueries({ queryKey: ["taxes", cafeId] });
|
||||
},
|
||||
});
|
||||
|
||||
const setDefault = useMutation({
|
||||
mutationFn: (id: string) => apiPatch(`/api/cafes/${cafeId}/taxes/${id}`, { isDefault: true }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["taxes", cafeId] }),
|
||||
});
|
||||
|
||||
const removeTax = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/taxes/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["taxes", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["menu-categories", cafeId] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemove = async (tax: TaxRow) => {
|
||||
const ok = await confirmDialog({
|
||||
description: t("deleteConfirm", { name: tax.name }),
|
||||
variant: "destructive",
|
||||
confirmLabel: tCommon("delete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
removeTax.mutate(tax.id);
|
||||
};
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
action={
|
||||
canEdit ? (
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={!name.trim() || !rate}
|
||||
onClick={() => addTax.mutate()}
|
||||
>
|
||||
<Plus className="me-2 h-4 w-4" />
|
||||
{t("addTax")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{!canEdit ? (
|
||||
<p className="text-sm text-muted-foreground">{t("ownerOnly")}</p>
|
||||
) : (
|
||||
<Card className="rounded-xl border border-border/80 bg-card shadow-sm">
|
||||
<CardContent className="grid gap-3 pt-6 sm:grid-cols-3">
|
||||
<LabeledField label={t("name")} htmlFor="tax-name">
|
||||
<Input id="tax-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("rate")} htmlFor="tax-rate">
|
||||
<Input
|
||||
id="tax-rate"
|
||||
value={rate}
|
||||
onChange={(e) => setRate(e.target.value)}
|
||||
inputMode="decimal"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<p className="text-xs text-muted-foreground sm:col-span-3">{t("hint")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : taxes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{taxes.map((tax) => (
|
||||
<li
|
||||
key={tax.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-border/80 bg-card px-4 py-3 shadow-sm transition-colors hover:border-[#0F6E56]/40"
|
||||
>
|
||||
<Receipt className="h-5 w-5 shrink-0 text-[#0F6E56]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{tax.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatNumber(tax.rate)}٪ — {tax.isRequired ? t("required") : t("optional")}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[#0F6E56]">{formatNumber(tax.rate)}٪</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{tax.isDefault ? (
|
||||
<Badge className="border-[#0F6E56]/30 bg-[#E1F5EE] text-[#0F6E56]">{t("default")}</Badge>
|
||||
) : canEdit ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setDefault.mutate(tax.id)}>
|
||||
{t("setDefault")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={removeTax.isPending}
|
||||
onClick={() => handleRemove(tax)}
|
||||
aria-label={t("delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user