2026-10-07 19:40:26 +03:30
|
|
|
"use client";
|
|
|
|
|
|
2026-10-09 19:05:59 +03:30
|
|
|
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import { useEffect, useState } from "react";
|
2026-10-07 19:40:26 +03:30
|
|
|
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 { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { toast } from "@/components/providers";
|
|
|
|
|
import { formatFaNumber } from "@/lib/utils";
|
2026-10-09 19:05:59 +03:30
|
|
|
import {
|
|
|
|
|
DEFAULT_PAGE_SIZE,
|
|
|
|
|
ListPagination,
|
|
|
|
|
buildPageQuery,
|
|
|
|
|
} from "@/components/admin/list-pagination";
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
|
|
|
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("");
|
2026-10-09 19:05:59 +03:30
|
|
|
const [page, setPage] = useState(1);
|
2026-10-07 19:40:26 +03:30
|
|
|
const [notes, setNotes] = useState<Record<number, string>>({});
|
|
|
|
|
const [tracking, setTracking] = useState<Record<number, string>>({});
|
|
|
|
|
const [busy, setBusy] = useState<number | null>(null);
|
|
|
|
|
|
2026-10-09 19:05:59 +03:30
|
|
|
useEffect(() => {
|
|
|
|
|
setPage(1);
|
|
|
|
|
}, [statusFilter]);
|
|
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
const { data, isLoading } = useQuery({
|
2026-10-09 19:05:59 +03:30
|
|
|
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,
|
2026-10-07 19:40:26 +03:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 (
|
2026-10-20 17:51:23 +03:30
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
<p className="text-xs text-muted-foreground">پیگیری درخواست و ارسال گواهینامه فیزیکی.</p>
|
2026-10-07 19:40:26 +03:30
|
|
|
<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)}
|
|
|
|
|
>
|
2026-10-20 17:51:23 +03:30
|
|
|
<option value="">همه وضعیتها</option>
|
2026-10-07 19:40:26 +03:30
|
|
|
<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>
|
2026-10-20 17:51:23 +03:30
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
2026-10-07 19:40:26 +03:30
|
|
|
{isLoading && (
|
|
|
|
|
<div className="flex justify-center py-8">
|
|
|
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{(data?.orders ?? []).map((o) => (
|
2026-10-20 17:51:23 +03:30
|
|
|
<div key={o.id} className="space-y-2 rounded-xl border border-border px-3 py-3">
|
2026-10-07 19:40:26 +03:30
|
|
|
<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 && (
|
2026-10-20 17:51:23 +03:30
|
|
|
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
|
|
|
|
سفارشی نیست.
|
|
|
|
|
</p>
|
2026-10-07 19:40:26 +03:30
|
|
|
)}
|
2026-10-09 19:05:59 +03:30
|
|
|
<ListPagination
|
|
|
|
|
page={page}
|
|
|
|
|
pageSize={data?.pageSize ?? DEFAULT_PAGE_SIZE}
|
|
|
|
|
total={data?.total ?? 0}
|
|
|
|
|
onPageChange={setPage}
|
|
|
|
|
/>
|
2026-10-20 17:51:23 +03:30
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-10-07 19:40:26 +03:30
|
|
|
);
|
|
|
|
|
}
|