Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
182 lines
7.0 KiB
TypeScript
182 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useEffect, useState } from "react";
|
|
import { Loader2 } from "lucide-react";
|
|
import { http, toUserError } from "@/lib/api";
|
|
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { toast } from "@/components/providers";
|
|
import { formatFaNumber } from "@/lib/utils";
|
|
import {
|
|
DEFAULT_PAGE_SIZE,
|
|
ListPagination,
|
|
buildPageQuery,
|
|
} from "@/components/admin/list-pagination";
|
|
|
|
const STATUS_LABEL: Record<PhysicalOrderStatus, string> = {
|
|
requested: "درخواستشده",
|
|
paid: "پرداختشده",
|
|
shipped: "ارسالشده",
|
|
cancelled: "لغو",
|
|
};
|
|
|
|
const NEXT: Partial<Record<PhysicalOrderStatus, PhysicalOrderStatus>> = {
|
|
requested: "paid",
|
|
paid: "shipped",
|
|
};
|
|
|
|
export function PhysicalOrdersPanel() {
|
|
const qc = useQueryClient();
|
|
const [statusFilter, setStatusFilter] = useState("");
|
|
const [page, setPage] = useState(1);
|
|
const [notes, setNotes] = useState<Record<number, string>>({});
|
|
const [tracking, setTracking] = useState<Record<number, string>>({});
|
|
const [busy, setBusy] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [statusFilter]);
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["admin", "physical-orders", statusFilter, page],
|
|
queryFn: () => {
|
|
const params = new URLSearchParams(buildPageQuery(page));
|
|
if (statusFilter) params.set("status", statusFilter);
|
|
return http.get<{
|
|
orders: CertificatePhysicalOrder[];
|
|
physical: PhysicalCertSettings;
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
}>(`/api/admin/physical-orders?${params.toString()}`);
|
|
},
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
|
|
const update = async (id: number, status: PhysicalOrderStatus) => {
|
|
setBusy(id);
|
|
try {
|
|
await http.put(`/api/admin/physical-orders/${id}`, {
|
|
status,
|
|
note: notes[id] ?? "",
|
|
trackingCode: tracking[id] ?? "",
|
|
});
|
|
toast.success("وضعیت بهروزرسانی شد — دانشجو مطلع میشود");
|
|
qc.invalidateQueries({ queryKey: ["admin", "physical-orders"] });
|
|
} catch (e) {
|
|
toast.error(toUserError(e));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3 space-y-0">
|
|
<CardTitle className="text-base">فهرست سفارشها</CardTitle>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<select
|
|
className="h-10 rounded-lg border border-input bg-card px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
>
|
|
<option value="">همه</option>
|
|
<option value="requested">درخواستشده</option>
|
|
<option value="paid">پرداختشده</option>
|
|
<option value="shipped">ارسالشده</option>
|
|
<option value="cancelled">لغو</option>
|
|
</select>
|
|
{data?.physical && (
|
|
<span className="text-xs text-muted-foreground">
|
|
قیمت: {formatFaNumber(data.physical.priceIrr)} ریال · پنجره:{" "}
|
|
{formatFaNumber(data.physical.windowDays)} روز
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{isLoading && (
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
{(data?.orders ?? []).map((o) => (
|
|
<div key={o.id} className="space-y-2 rounded-md border border-border px-3 py-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<p className="text-sm font-medium">{o.userName ?? `کاربر ${o.userId}`}</p>
|
|
<p className="text-xs text-muted-foreground" dir="ltr">
|
|
#{o.id} · cert {o.publicId}
|
|
</p>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
مهلت پنجره: {new Date(o.windowEndsAt).toLocaleString("fa-IR")}
|
|
</p>
|
|
{(o.address || o.city) && (
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{[o.recipientName, o.phone, o.city, o.postalCode].filter(Boolean).join(" · ")}
|
|
<br />
|
|
{o.address}
|
|
</p>
|
|
)}
|
|
{o.trackingCode && (
|
|
<p className="text-xs font-mono" dir="ltr">
|
|
tracking: {o.trackingCode}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Badge variant={o.status === "cancelled" ? "destructive" : "secondary"}>
|
|
{STATUS_LABEL[o.status]}
|
|
</Badge>
|
|
</div>
|
|
{o.note && <p className="text-xs text-muted-foreground">یادداشت: {o.note}</p>}
|
|
<Input
|
|
placeholder="یادداشت ادمین…"
|
|
value={notes[o.id] ?? ""}
|
|
onChange={(e) => setNotes((n) => ({ ...n, [o.id]: e.target.value }))}
|
|
/>
|
|
{(o.status === "paid" || o.status === "shipped") && (
|
|
<Input
|
|
placeholder="کد پیگیری ارسال…"
|
|
value={tracking[o.id] ?? o.trackingCode ?? ""}
|
|
onChange={(e) => setTracking((t) => ({ ...t, [o.id]: e.target.value }))}
|
|
dir="ltr"
|
|
/>
|
|
)}
|
|
<div className="flex flex-wrap gap-2">
|
|
{NEXT[o.status] && (
|
|
<Button size="sm" disabled={busy === o.id} onClick={() => update(o.id, NEXT[o.status]!)}>
|
|
{busy === o.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
|
انتقال به {STATUS_LABEL[NEXT[o.status]!]}
|
|
</Button>
|
|
)}
|
|
{o.status !== "cancelled" && o.status !== "shipped" && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={busy === o.id}
|
|
onClick={() => update(o.id, "cancelled")}
|
|
>
|
|
لغو
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{!isLoading && (data?.orders ?? []).length === 0 && (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p>
|
|
)}
|
|
<ListPagination
|
|
page={page}
|
|
pageSize={data?.pageSize ?? DEFAULT_PAGE_SIZE}
|
|
total={data?.total ?? 0}
|
|
onPageChange={setPage}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|