- Reusable admin primitives: AdminTable, AdminPagination, AdminToolbar, AdminSearch/Select, StatusBadge + fa number/date/toman + useDebouncedValue. - Four board routes with search, filters, paging: orders (read-only), sellers (approve/reject/verify), users (ban/unban), products (active toggle). Wired into the admin sidebar nav + hooks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
249 lines
6.8 KiB
TypeScript
249 lines
6.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { ChevronLeft, ChevronRight, Search, Loader2 } from "lucide-react";
|
||
import { cn } from "~/lib/utils";
|
||
|
||
/* ------------------------------ helpers ------------------------------ */
|
||
|
||
export const faNum = (n?: number | string | null) =>
|
||
n === null || n === undefined || n === ""
|
||
? "—"
|
||
: Number(n).toLocaleString("fa-IR");
|
||
|
||
export const faToman = (n?: number | null) =>
|
||
n === null || n === undefined ? "—" : `${Math.round(n).toLocaleString("fa-IR")} تومان`;
|
||
|
||
export const faDate = (iso?: string | null) => {
|
||
if (!iso) return "—";
|
||
try {
|
||
return new Date(iso).toLocaleDateString("fa-IR", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
});
|
||
} catch {
|
||
return "—";
|
||
}
|
||
};
|
||
|
||
/** Debounce a rapidly-changing value (e.g. a search box). */
|
||
export function useDebouncedValue<T>(value: T, delay = 350): T {
|
||
const [debounced, setDebounced] = useState(value);
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setDebounced(value), delay);
|
||
return () => clearTimeout(t);
|
||
}, [value, delay]);
|
||
return debounced;
|
||
}
|
||
|
||
/* ---------------------------- status badge --------------------------- */
|
||
|
||
type Tone = "green" | "red" | "yellow" | "blue" | "gray";
|
||
|
||
const TONE: Record<Tone, string> = {
|
||
green: "bg-GREEN/10 text-GREEN",
|
||
red: "bg-RED/10 text-RED",
|
||
yellow: "bg-yellow-500/10 text-yellow-600",
|
||
blue: "bg-VITROWN_BLUE/10 text-VITROWN_BLUE",
|
||
gray: "bg-WHITE3 text-BLACK2",
|
||
};
|
||
|
||
export function StatusBadge({ label, tone }: { label: string; tone: Tone }) {
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"inline-block px-2 py-0.5 rounded-full text-[11px] font-semibold whitespace-nowrap",
|
||
TONE[tone]
|
||
)}
|
||
>
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------- table ------------------------------- */
|
||
|
||
export interface Column<T> {
|
||
header: string;
|
||
render: (row: T) => React.ReactNode;
|
||
className?: string;
|
||
}
|
||
|
||
export function AdminTable<T>({
|
||
columns,
|
||
rows,
|
||
isLoading,
|
||
getRowKey,
|
||
emptyText = "موردی یافت نشد",
|
||
}: {
|
||
columns: Column<T>[];
|
||
rows: T[];
|
||
isLoading?: boolean;
|
||
getRowKey: (row: T) => string;
|
||
emptyText?: string;
|
||
}) {
|
||
return (
|
||
<div className="rounded-2xl border border-WHITE3 bg-WHITE overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-right border-collapse">
|
||
<thead>
|
||
<tr className="bg-WHITE2 text-GRAY text-[12px]">
|
||
{columns.map((c, i) => (
|
||
<th
|
||
key={i}
|
||
className={cn("px-3 py-3 font-bold whitespace-nowrap", c.className)}
|
||
>
|
||
{c.header}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{isLoading ? (
|
||
<tr>
|
||
<td colSpan={columns.length} className="py-12 text-center">
|
||
<Loader2 className="inline h-6 w-6 animate-spin text-GRAY" />
|
||
</td>
|
||
</tr>
|
||
) : rows.length === 0 ? (
|
||
<tr>
|
||
<td
|
||
colSpan={columns.length}
|
||
className="py-12 text-center text-GRAY text-[13px]"
|
||
>
|
||
{emptyText}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
rows.map((row) => (
|
||
<tr
|
||
key={getRowKey(row)}
|
||
className="border-t border-WHITE3 hover:bg-WHITE2/60 transition-colors text-[13px]"
|
||
>
|
||
{columns.map((c, i) => (
|
||
<td
|
||
key={i}
|
||
className={cn("px-3 py-3 align-middle", c.className)}
|
||
>
|
||
{c.render(row)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------------------------- pagination ----------------------------- */
|
||
|
||
export function AdminPagination({
|
||
page,
|
||
pageSize,
|
||
count,
|
||
onPageChange,
|
||
}: {
|
||
page: number;
|
||
pageSize: number;
|
||
count: number;
|
||
onPageChange: (page: number) => void;
|
||
}) {
|
||
const totalPages = Math.max(1, Math.ceil(count / pageSize));
|
||
const from = count === 0 ? 0 : (page - 1) * pageSize + 1;
|
||
const to = Math.min(count, page * pageSize);
|
||
const btn =
|
||
"h-8 w-8 grid place-items-center rounded-lg border border-WHITE3 text-BLACK2 disabled:opacity-40 hover:bg-WHITE2 transition-colors cursor-pointer disabled:cursor-default";
|
||
|
||
return (
|
||
<div className="flex items-center justify-between mt-3 text-[13px] text-GRAY">
|
||
<span>
|
||
{faNum(from)}–{faNum(to)} از {faNum(count)}
|
||
</span>
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
className={btn}
|
||
disabled={page <= 1}
|
||
onClick={() => onPageChange(page - 1)}
|
||
aria-label="صفحه قبل"
|
||
>
|
||
<ChevronRight size={16} />
|
||
</button>
|
||
<span className="tabular-nums px-1">
|
||
{faNum(page)} / {faNum(totalPages)}
|
||
</span>
|
||
<button
|
||
className={btn}
|
||
disabled={page >= totalPages}
|
||
onClick={() => onPageChange(page + 1)}
|
||
aria-label="صفحه بعد"
|
||
>
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* --------------------------- toolbar bits ---------------------------- */
|
||
|
||
export function AdminSearch({
|
||
value,
|
||
onChange,
|
||
placeholder = "جستجو…",
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
}) {
|
||
return (
|
||
<div className="relative flex-1 min-w-[180px] max-w-[320px]">
|
||
<Search
|
||
size={16}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-GRAY"
|
||
/>
|
||
<input
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
className="w-full rounded-xl border border-WHITE3 bg-WHITE pr-9 pl-3 py-2 text-[13px] outline-none focus:border-VITROWN_BLUE"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AdminSelect({
|
||
value,
|
||
onChange,
|
||
options,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
options: { value: string; label: string }[];
|
||
}) {
|
||
return (
|
||
<select
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className="rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13px] outline-none focus:border-VITROWN_BLUE cursor-pointer"
|
||
>
|
||
{options.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
export function AdminToolbar({ children }: { children: React.ReactNode }) {
|
||
return <div className="flex flex-wrap items-center gap-2 mb-3">{children}</div>;
|
||
}
|
||
|
||
export function AdminPageTitle({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<h1 className="text-[22px] lg:text-[26px] font-extrabold mb-4">{children}</h1>
|
||
);
|
||
}
|