Files
warehouse-query/app/page.tsx
Misaka_Company 7d6de48df3 chore: add env example and fix default sort order
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 09:53:50 +08:00

445 lines
21 KiB
TypeScript
Raw 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.
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
Search, ChevronLeft, ChevronRight, ChevronUp, ChevronDown, X,
CheckCircle2, Circle, ArrowUpDown, Boxes, SlidersHorizontal, Loader2,
} from "lucide-react";
import type { WarehouseRow, QueryResult, FilterOptions } from "@/types/warehouse";
/* ── 品牌色:沿用原 Excel 的深蓝表头 + 淡蓝隔行 ── */
const NAVY = "#1F497D";
const NAVY_DARK = "#163758";
const FONT =
'"PingFang SC","Microsoft YaHei","Noto Sans SC",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif';
/* ── 列定义(顺序与原 Excel 表头一致)── */
const COLUMNS = [
{ key: "paichan", label: "排产号", mono: true, align: "left" },
{ key: "zongpai", label: "总排号", mono: true, align: "left" },
{ key: "workshop", label: "车间", align: "left" },
{ key: "model", label: "产品型号", strong: true, align: "left" },
{ key: "range", label: "量程", align: "left" },
{ key: "qty", label: "数量", num: true, align: "right" },
{ key: "boxNo", label: "箱号", mono: true, align: "left" },
{ key: "boxQty", label: "装箱数量", num: true, align: "right" },
{ key: "workOrder", label: "工令号", mono: true, align: "left" },
{ key: "shelf", label: "货架", mono: true, align: "left" },
{ key: "handler", label: "经办人", align: "left" },
{ key: "orderNo", label: "订单号", mono: true, align: "left" },
{ key: "inbound", label: "入库时间", mono: true, align: "left" },
];
/* ── Sub-components ── */
function SortIcon({ active, dir }: { active: boolean; dir: string }) {
if (!active) return <ArrowUpDown className="h-3.5 w-3.5 opacity-40" />;
return dir === "asc" ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />;
}
function StatusBadge({ status }: { status: "IN" | "WAIT" }) {
if (status === "IN") {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-700 ring-1 ring-emerald-200">
<CheckCircle2 className="h-3.5 w-3.5" />
</span>
);
}
return (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-700 ring-1 ring-amber-200">
<Circle className="h-3.5 w-3.5" />
</span>
);
}
function Field({ label, children, className = "" }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={`flex flex-col gap-1 ${className}`}>
<label className="text-xs font-medium text-slate-500">{label}</label>
{children}
</div>
);
}
function SelectWrap({ value, onChange, children }: { value: string; onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void; children: React.ReactNode }) {
return (
<div className="relative">
<select
value={value}
onChange={onChange}
className="w-full appearance-none rounded-lg border border-slate-200 bg-slate-50 py-2 pl-3 pr-8 text-sm text-slate-700 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-200"
>
{children}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
</div>
);
}
function Chip({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-200">
{children}
<button onClick={onRemove} className="grid h-4 w-4 place-items-center rounded-full hover:bg-blue-200/70">
<X className="h-3 w-3" />
</button>
</span>
);
}
/* ── Main Page Component ── */
export default function WarehouseDashboard() {
const [search, setSearch] = useState("");
const [workshop, setWorkshop] = useState("ALL");
const [handler, setHandler] = useState("ALL");
const [status, setStatus] = useState<"ALL" | "IN" | "WAIT">("ALL");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [sortKey, setSortKey] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<QueryResult | null>(null);
const [filterOptions, setFilterOptions] = useState<FilterOptions | null>(null);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
/* Fetch filter options on mount */
useEffect(() => {
(async () => {
try {
const res = await fetch("/api/warehouse/filters");
if (!res.ok) throw new Error("Failed to load filter options");
const json: FilterOptions = await res.json();
setFilterOptions(json);
} catch {
// Filter options are not critical — silently degrade
}
})();
}, []);
/* Build fetch callback */
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
const params = new URLSearchParams();
if (search) params.set("search", search);
if (workshop !== "ALL") params.set("workshop", workshop);
if (handler !== "ALL") params.set("handler", handler);
if (status !== "ALL") params.set("status", status);
if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo);
if (sortKey) {
params.set("sortKey", sortKey);
params.set("sortDir", sortDir);
}
params.set("page", String(page));
params.set("pageSize", String(pageSize));
try {
const res = await fetch(`/api/warehouse?${params}`);
if (!res.ok) throw new Error("Query failed");
const json: QueryResult = await res.json();
setData(json);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, [search, workshop, handler, status, dateFrom, dateTo, sortKey, sortDir, page, pageSize]);
/* Debounced effect: fetch data when any dependency changes */
useEffect(() => {
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(() => {
fetchData();
}, search ? 300 : 0);
return () => {
if (searchTimeout.current) clearTimeout(searchTimeout.current);
};
}, [fetchData, search]);
/* Derived values */
const total = data?.total ?? 0;
const totalPages = data?.totalPages ?? 1;
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
const rows: WarehouseRow[] = data?.rows ?? [];
const filtersActive = search || workshop !== "ALL" || handler !== "ALL" || status !== "ALL" || dateFrom || dateTo;
const toggleSort = (key: string) => {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
setPage(1);
};
const resetFilters = () => {
setSearch(""); setWorkshop("ALL"); setHandler("ALL"); setStatus("ALL");
setDateFrom(""); setDateTo(""); setSortKey(""); setSortDir("asc");
setPage(1);
};
const STATUS_LABEL: Record<string, string> = { IN: "已入库", WAIT: "待入库" };
const dateInputCls =
"rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-200";
return (
<div className="min-h-screen bg-slate-100 antialiased" style={{ fontFamily: FONT }}>
{/* ── 顶部应用栏 ── */}
<header className="text-white" style={{ background: `linear-gradient(135deg, ${NAVY} 0%, ${NAVY_DARK} 100%)` }}>
<div className="mx-auto flex max-w-screen-2xl flex-wrap items-center justify-between gap-4 px-6 py-4">
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-white/10 ring-1 ring-white/20">
<Boxes className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-lg font-semibold leading-tight tracking-tight"></h1>
<p className="text-xs text-white/70">Finished Goods Warehouse · </p>
</div>
</div>
<div className="hidden items-center gap-2 rounded-lg bg-white/10 px-3 py-1.5 text-xs ring-1 ring-white/15 sm:flex">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
</span>
<span className="text-white/90"></span>
<span className="text-white/40">·</span>
<span className="font-mono text-white/80">CompanyDB · 192.168.110.114</span>
</div>
</div>
</header>
<main className="mx-auto max-w-screen-2xl space-y-5 px-6 py-6">
{/* ── 查询条件(核心)── */}
<section className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3">
<SlidersHorizontal className="h-4 w-4 text-slate-500" />
<h2 className="text-sm font-semibold text-slate-700"></h2>
<span className="ml-auto text-sm text-slate-500">
<span className="font-semibold tabular-nums text-slate-800">{total}</span>
</span>
</div>
<div className="space-y-4 p-5">
{/* 主搜索框 */}
<div className="relative">
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="输入 总排号 / 工令号 / 订单号 / 产品型号 / 箱号 / 货架 进行查询…"
className="w-full rounded-xl border border-slate-200 bg-slate-50 py-3 pl-11 pr-10 text-base text-slate-800 placeholder:text-slate-400 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-300"
/>
{search && (
<button
onClick={() => { setSearch(""); setPage(1); }}
className="absolute right-3 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-md text-slate-400 hover:bg-slate-200 hover:text-slate-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* 筛选项 */}
<div className="flex flex-wrap gap-4">
<Field label="车间" className="w-44">
<SelectWrap value={workshop} onChange={(e) => { setWorkshop(e.target.value); setPage(1); }}>
<option value="ALL"></option>
{filterOptions?.workshops.map((w) => (
<option key={w.code} value={w.code}>{w.name}</option>
))}
</SelectWrap>
</Field>
<Field label="经办人" className="w-40">
<SelectWrap value={handler} onChange={(e) => { setHandler(e.target.value); setPage(1); }}>
<option value="ALL"></option>
{filterOptions?.handlers.map((h) => (
<option key={h} value={h}>{h}</option>
))}
</SelectWrap>
</Field>
<Field label="状态" className="w-36">
<SelectWrap value={status} onChange={(e) => { setStatus(e.target.value as "ALL" | "IN" | "WAIT"); setPage(1); }}>
<option value="ALL"></option>
<option value="IN"></option>
<option value="WAIT"></option>
</SelectWrap>
</Field>
<Field label="入库时间">
<div className="flex items-center gap-2">
<input type="date" value={dateFrom} onChange={(e) => { setDateFrom(e.target.value); setPage(1); }} className={dateInputCls} />
<span className="text-sm text-slate-400"></span>
<input type="date" value={dateTo} onChange={(e) => { setDateTo(e.target.value); setPage(1); }} className={dateInputCls} />
</div>
</Field>
</div>
{/* 已选条件标签 */}
{filtersActive && (
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-3">
<span className="text-xs text-slate-400"></span>
{search && <Chip onRemove={() => { setSearch(""); setPage(1); }}>{search}</Chip>}
{workshop !== "ALL" && (
<Chip onRemove={() => { setWorkshop("ALL"); setPage(1); }}>
{filterOptions?.workshops.find(w => w.code === workshop)?.name ?? workshop}
</Chip>
)}
{handler !== "ALL" && <Chip onRemove={() => { setHandler("ALL"); setPage(1); }}>{handler}</Chip>}
{status !== "ALL" && <Chip onRemove={() => { setStatus("ALL"); setPage(1); }}>{STATUS_LABEL[status]}</Chip>}
{dateFrom && <Chip onRemove={() => { setDateFrom(""); setPage(1); }}>{dateFrom}</Chip>}
{dateTo && <Chip onRemove={() => { setDateTo(""); setPage(1); }}>{dateTo}</Chip>}
<button onClick={resetFilters} className="ml-1 text-xs text-slate-500 hover:text-slate-700 hover:underline">
</button>
</div>
)}
</div>
</section>
{/* ── 查询结果 ── */}
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-4 py-3">
<h2 className="text-sm font-semibold text-slate-700"></h2>
<span className="text-xs text-slate-400"> + + + LEFT JOIN</span>
</div>
{/* Error banner */}
{error && (
<div className="border-b border-red-200 bg-red-50 px-5 py-3 text-sm text-red-700">
{error}
</div>
)}
{/* Loading / Table area */}
<div className="relative">
{loading && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-white/70">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<span className="mt-2 text-sm text-slate-500">...</span>
</div>
)}
<div className="overflow-auto" style={{ maxHeight: "60vh" }}>
<table className="w-full border-collapse text-sm">
<thead>
<tr>
{COLUMNS.map((col) => (
<th
key={col.key}
onClick={() => toggleSort(col.key)}
className={`sticky top-0 z-10 cursor-pointer select-none whitespace-nowrap px-4 py-3 font-semibold text-white transition hover:brightness-110 ${col.align === "right" ? "text-right" : "text-left"}`}
style={{ background: NAVY }}
>
<span className={`inline-flex items-center gap-1 ${col.align === "right" ? "flex-row-reverse" : ""}`}>
{col.label}
<SortIcon active={sortKey === col.key} dir={sortDir} />
</span>
</th>
))}
<th className="sticky top-0 z-10 whitespace-nowrap px-4 py-3 text-left font-semibold text-white" style={{ background: NAVY }}>
</th>
</tr>
</thead>
<tbody>
{!loading && rows.length === 0 ? (
<tr>
<td colSpan={COLUMNS.length + 1} className="px-4 py-16 text-center">
<div className="flex flex-col items-center gap-2 text-slate-400">
<Search className="h-8 w-8" />
<div className="text-sm"></div>
<button onClick={resetFilters} className="text-sm text-blue-600 hover:underline"></button>
</div>
</td>
</tr>
) : (
rows.map((r, i) => (
<tr
key={`${r.zongpai}-${r.boxNo}-${i}`}
className={`transition-colors ${i % 2 === 0 ? "bg-white hover:bg-slate-50" : "bg-blue-50 hover:bg-blue-100"}`}
>
{COLUMNS.map((col) => {
const raw = r[col.key as keyof WarehouseRow];
const empty = raw === "" || raw == null;
const align = col.align === "right" ? "text-right" : "text-left";
const font = col.mono ? "font-mono" : "";
const num = col.num ? "tabular-nums" : "";
const color = col.strong
? "font-medium text-slate-900"
: col.mono ? "text-slate-600" : "text-slate-700";
return (
<td key={col.key} className={`whitespace-nowrap border-b border-slate-100 px-4 py-2.5 ${align} ${font} ${num} ${color}`}>
{empty ? <span className="text-slate-300"></span> : String(raw)}
</td>
);
})}
<td className="whitespace-nowrap border-b border-slate-100 px-4 py-2.5 text-left">
<StatusBadge status={r.status} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 分页 */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-sm text-slate-500">
<span className="font-medium tabular-nums text-slate-700">{total === 0 ? 0 : start + 1}</span>
<span className="font-medium tabular-nums text-slate-700">{Math.min(start + pageSize, total)}</span>
<span className="font-medium tabular-nums text-slate-700">{total}</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm text-slate-500">
<div className="relative">
<select
value={pageSize}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className="appearance-none rounded-lg border border-slate-200 bg-white py-1.5 pl-3 pr-7 text-sm text-slate-700 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-200"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
<ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={safePage <= 1}
className="grid h-8 w-8 place-items-center rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="px-2 text-sm tabular-nums text-slate-600">{safePage} / {totalPages}</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
className="grid h-8 w-8 place-items-center rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
</section>
</main>
</div>
);
}