Files
pargar/frontend/src/components/admin/physical-orders-panel.tsx
T
ahp d9a2abb44f feat: release v0.1.3 chat, profiles, and media
Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
2026-10-09 19:05:59 +03:30

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>
);
}