190 lines
4.9 KiB
TypeScript
190 lines
4.9 KiB
TypeScript
import { useState, useMemo, useCallback } from "react";
|
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
import { Search, Plus, Pencil, Trash2, Truck } from "lucide-react";
|
|
import { GridTable } from "#/components/GridTable";
|
|
import type { GridTableColumn } from "#/components/GridTable";
|
|
import {
|
|
useListDistributors,
|
|
useRemoveDistributor,
|
|
trpc,
|
|
} from "shared-react";
|
|
|
|
interface DistributorRow {
|
|
id: number;
|
|
agency: string;
|
|
contact: string;
|
|
mobile: string;
|
|
address: string | null;
|
|
}
|
|
|
|
function makeColumns(
|
|
onDelete: (row: DistributorRow) => void,
|
|
): GridTableColumn<DistributorRow>[] {
|
|
return [
|
|
{
|
|
id: "agency",
|
|
header: "Agency Name",
|
|
cell: ({ row }) => (
|
|
<Link
|
|
to="/distributors/$id"
|
|
params={{ id: row.id.toString() }}
|
|
className="font-medium text-blue-600 hover:underline"
|
|
>
|
|
{row.agency}
|
|
</Link>
|
|
),
|
|
},
|
|
{
|
|
id: "contact",
|
|
header: "Contact Person",
|
|
cell: ({ row }) => (
|
|
<span className="text-sm">{row.contact}</span>
|
|
),
|
|
},
|
|
{
|
|
id: "mobile",
|
|
header: "Contact Mobile",
|
|
cell: ({ row }) => (
|
|
<span className="text-sm text-slate-600 font-mono">
|
|
{row.mobile}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: "address",
|
|
header: "Address",
|
|
cell: ({ row }) => (
|
|
<span
|
|
className="text-sm text-slate-600 max-w-[200px] truncate block"
|
|
title={row.address ?? undefined}
|
|
>
|
|
{row.address || "—"}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "Actions",
|
|
size: 90,
|
|
cell: ({ row }) => (
|
|
<div className="flex items-center justify-center gap-1">
|
|
<button
|
|
type="button"
|
|
className="w-8 h-8 flex items-center justify-center rounded-md text-slate-500 hover:text-blue-600 hover:bg-blue-50 transition-colors"
|
|
aria-label={`Edit ${row.agency}`}
|
|
>
|
|
<Pencil className="w-[15px] h-[15px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-8 h-8 flex items-center justify-center rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 transition-colors"
|
|
aria-label={`Delete ${row.agency}`}
|
|
onClick={() => onDelete(row)}
|
|
>
|
|
<Trash2 className="w-[15px] h-[15px]" />
|
|
</button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
}
|
|
|
|
export const Route = createFileRoute("/distributors/")({
|
|
component: DistributorsIndexPage,
|
|
staticData: {
|
|
title: "Distributors",
|
|
subtitle: "Manage product distributors & agencies",
|
|
},
|
|
});
|
|
|
|
function DistributorsIndexPage() {
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const { data: distributors, isLoading, error } = useListDistributors();
|
|
const removeMutation = useRemoveDistributor();
|
|
const utils = trpc.useUtils();
|
|
|
|
const handleDelete = useCallback(
|
|
(row: DistributorRow) => {
|
|
if (!confirm(`Delete ${row.agency}?`)) return;
|
|
removeMutation.mutate(
|
|
{ id: row.id },
|
|
{
|
|
onSuccess: () => utils.distributor.list.invalidate(),
|
|
},
|
|
);
|
|
},
|
|
[removeMutation, utils],
|
|
);
|
|
|
|
const columns = useMemo(() => makeColumns(handleDelete), [handleDelete]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = searchQuery.toLowerCase().trim();
|
|
if (!q) return distributors ?? [];
|
|
return (distributors ?? []).filter((d) => {
|
|
return (
|
|
d.agency.toLowerCase().includes(q) ||
|
|
d.contact.toLowerCase().includes(q) ||
|
|
d.mobile.includes(q)
|
|
);
|
|
});
|
|
}, [searchQuery, distributors]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="text-sm text-slate-600 py-8">
|
|
Loading distributors...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="text-sm text-red-600 py-8">
|
|
Failed to load distributors.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-5 flex-wrap">
|
|
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-[400px] px-3.5 py-2 bg-white rounded-md border border-slate-200 transition-all duration-200 focus-within:border-blue-600 focus-within:shadow-[0_0_0_3px_rgba(37,99,235,0.1)]">
|
|
<Search className="w-4 h-4 text-slate-600 shrink-0" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search distributors..."
|
|
className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-400"
|
|
/>
|
|
</div>
|
|
<Link
|
|
to="/distributors/add"
|
|
className="inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
|
|
>
|
|
<Plus className="w-[15px] h-[15px]" />
|
|
Add Distributor
|
|
</Link>
|
|
</div>
|
|
|
|
<GridTable
|
|
columns={columns}
|
|
data={filtered}
|
|
emptyState={
|
|
<div className="flex flex-col items-center py-16 px-6 text-slate-600">
|
|
<Truck className="w-12 h-12 mb-4 opacity-40" />
|
|
<h3 className="text-base font-semibold text-slate-900 mb-1.5">
|
|
No distributors found
|
|
</h3>
|
|
<p className="text-sm">
|
|
{searchQuery
|
|
? "No distributors match your search. Try a different term."
|
|
: "Add your first distributor to get started."}
|
|
</p>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|