Vitron-Front/app/components/admin/DataTable.tsx
Arda Samadi 935c337df0 feat(admin): monitoring boards UI — orders/sellers/users/products (slice 1)
- 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>
2026-07-25 12:17:22 +03:30

249 lines
6.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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