Files
star-erp/resources/js/Pages/Inventory/StockQuery/Index.tsx

579 lines
24 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.
import { useState } from "react";
import { Head, router } from "@inertiajs/react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import {
Search,
Package,
AlertTriangle,
MinusCircle,
Clock,
Download,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import Pagination from "@/Components/shared/Pagination";
interface InventoryItem {
id: number;
product_code: string;
product_name: string;
category_name: string | null;
warehouse_name: string;
batch_number: string | null;
quantity: number;
safety_stock: number | null;
expiry_date: string | null;
quality_status: string | null;
last_inbound: string | null;
last_outbound: string | null;
statuses: string[];
location: string | null;
}
interface PaginationLink {
url: string | null;
label: string;
active: boolean;
}
interface Props {
filters: {
warehouse_id?: string;
category_id?: string;
search?: string;
status?: string;
sort_by?: string;
sort_order?: string;
per_page?: string;
};
summary: {
totalItems: number;
lowStockCount: number;
negativeCount: number;
expiringCount: number;
};
inventories: {
data: InventoryItem[];
total: number;
per_page: number;
current_page: number;
last_page: number;
links: PaginationLink[];
};
warehouses: { id: number; name: string }[];
categories: { id: number; name: string }[];
}
// 狀態 Badge
const statusConfig: Record<
string,
{ label: string; className: string }
> = {
normal: {
label: "正常",
className: "bg-green-100 text-green-800 border-green-200",
},
negative: {
label: "負庫存",
className: "bg-red-100 text-red-800 border-red-200",
},
low_stock: {
label: "低庫存",
className: "bg-amber-100 text-amber-800 border-amber-200",
},
expiring: {
label: "即將過期",
className: "bg-yellow-100 text-yellow-800 border-yellow-200",
},
expired: {
label: "已過期",
className: "bg-red-100 text-red-800 border-red-200",
},
};
// 狀態篩選選項
const statusOptions = [
{ label: "全部狀態", value: "" },
{ label: "低庫存", value: "low_stock" },
{ label: "負庫存", value: "negative" },
{ label: "即將過期", value: "expiring" },
{ label: "已過期", value: "expired" },
{ label: "所有異常", value: "abnormal" },
];
export default function StockQueryIndex({
filters,
summary,
inventories,
warehouses,
categories,
}: Props) {
const [search, setSearch] = useState(filters.search || "");
const [perPage, setPerPage] = useState<string>(
filters.per_page || "10"
);
// 執行篩選
const applyFilters = (newFilters: Record<string, string | undefined>) => {
const merged = { ...filters, ...newFilters, page: undefined };
// 移除空值
const cleaned: Record<string, string> = {};
Object.entries(merged).forEach(([key, value]) => {
if (value !== undefined && value !== "" && value !== null) {
cleaned[key] = String(value);
}
});
router.get(route("inventory.stock-query.index"), cleaned, {
preserveState: true,
replace: true,
});
};
// 搜尋
const handleSearch = () => {
applyFilters({ search: search || undefined });
};
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
// 排序
const handleSort = (field: string) => {
let newSortBy: string | undefined = field;
let newSortOrder: string | undefined = "asc";
if (filters.sort_by === field) {
if (filters.sort_order === "asc") {
newSortOrder = "desc";
} else {
newSortBy = undefined;
newSortOrder = undefined;
}
}
applyFilters({ sort_by: newSortBy, sort_order: newSortOrder });
};
// 排序圖標
const SortIcon = ({ field }: { field: string }) => {
if (filters.sort_by !== field) {
return (
<ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />
);
}
if (filters.sort_order === "asc") {
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
}
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
};
// 每頁筆數變更
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route("inventory.stock-query.index"),
{ ...filters, per_page: value, page: undefined },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
// 匯出
const handleExport = () => {
const params = new URLSearchParams();
if (filters.warehouse_id)
params.append("warehouse_id", filters.warehouse_id);
if (filters.category_id)
params.append("category_id", filters.category_id);
if (filters.search) params.append("search", filters.search);
if (filters.status) params.append("status", filters.status);
window.location.href =
route("inventory.stock-query.export") + "?" + params.toString();
};
// 計算序號起始值
const startIndex =
(inventories.current_page - 1) * inventories.per_page + 1;
// 統計卡片
const cards = [
{
label: "庫存品項",
value: summary.totalItems,
icon: <Package className="h-5 w-5" />,
color: "text-primary-main",
bgColor: "bg-primary-lightest",
borderColor: "border-primary-light",
status: "",
},
{
label: "低庫存",
value: summary.lowStockCount,
icon: <AlertTriangle className="h-5 w-5" />,
color: "text-amber-600",
bgColor: "bg-amber-50",
borderColor: "border-amber-200",
status: "low_stock",
alert: summary.lowStockCount > 0,
},
{
label: "負庫存",
value: summary.negativeCount,
icon: <MinusCircle className="h-5 w-5" />,
color: "text-red-600",
bgColor: "bg-red-50",
borderColor: "border-red-200",
status: "negative",
alert: summary.negativeCount > 0,
},
{
label: "即將過期",
value: summary.expiringCount,
icon: <Clock className="h-5 w-5" />,
color: "text-yellow-600",
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200",
status: "expiring",
alert: summary.expiringCount > 0,
},
];
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "商品與庫存管理", href: "#" },
{
label: "即時庫存查詢",
href: route("inventory.stock-query.index"),
isPage: true,
},
]}
>
<Head title="即時庫存查詢" />
<div className="container mx-auto p-6 max-w-7xl">
{/* 頁面標題 */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Search className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
<Button
className="button-filled-primary gap-2"
onClick={handleExport}
>
<Download className="h-4 w-4" />
Excel
</Button>
</div>
{/* 統計卡片 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{cards.map((card) => (
<div
key={card.label}
onClick={() =>
applyFilters({
status: card.status || undefined,
})
}
className={`relative rounded-xl border ${card.borderColor} ${card.bgColor} p-4 transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer ${filters.status === card.status
? "ring-2 ring-primary-main ring-offset-1"
: ""
}`}
>
{card.alert && (
<span className="absolute top-2.5 right-2.5 h-2 w-2 rounded-full bg-red-500 animate-pulse" />
)}
<div className="flex items-center gap-2 mb-2">
<div className={card.color}>{card.icon}</div>
<span className="text-xs font-medium text-grey-2">
{card.label}
</span>
</div>
<div
className={`text-2xl font-bold ${card.color}`}
>
{card.value.toLocaleString()}
</div>
</div>
))}
</div>
{/* 篩選列 */}
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-4">
<div className="flex flex-wrap items-center gap-3">
<SearchableSelect
value={filters.warehouse_id || ""}
onValueChange={(v) =>
applyFilters({
warehouse_id: v || undefined,
})
}
options={[
{ label: "全部倉庫", value: "" },
...warehouses.map((w) => ({
label: w.name,
value: String(w.id),
})),
]}
className="w-[160px] h-9"
placeholder="選擇倉庫"
/>
<SearchableSelect
value={filters.category_id || ""}
onValueChange={(v) =>
applyFilters({
category_id: v || undefined,
})
}
options={[
{ label: "全部分類", value: "" },
...categories.map((c) => ({
label: c.name,
value: String(c.id),
})),
]}
className="w-[160px] h-9"
placeholder="選擇分類"
/>
<SearchableSelect
value={filters.status || ""}
onValueChange={(v) =>
applyFilters({ status: v || undefined })
}
options={statusOptions}
className="w-[140px] h-9"
showSearch={false}
placeholder="篩選狀態"
/>
<div className="flex items-center gap-2 flex-1 min-w-[200px]">
<Input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="搜尋商品代碼或名稱..."
className="h-9"
/>
<Button
variant="outline"
size="sm"
className="button-outlined-primary h-9"
onClick={handleSearch}
>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* 庫存明細表格 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center">
#
</TableHead>
<TableHead>
<button
onClick={() =>
handleSort("products.code")
}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="products.code" />
</button>
</TableHead>
<TableHead>
<button
onClick={() =>
handleSort("products.name")
}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="products.name" />
</button>
</TableHead>
<TableHead></TableHead>
<TableHead>
<button
onClick={() =>
handleSort("warehouses.name")
}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="warehouses.name" />
</button>
</TableHead>
<TableHead></TableHead>
<TableHead>/</TableHead>
<TableHead>
<button
onClick={() =>
handleSort("inventories.quantity")
}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="inventories.quantity" />
</button>
</TableHead>
<TableHead></TableHead>
<TableHead>
<button
onClick={() =>
handleSort(
"inventories.expiry_date"
)
}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="inventories.expiry_date" />
</button>
</TableHead>
<TableHead className="text-center">
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inventories.data.length === 0 ? (
<TableRow>
<TableCell
colSpan={13}
className="text-center py-8 text-gray-500"
>
</TableCell>
</TableRow>
) : (
inventories.data.map((item: InventoryItem, index) => (
<TableRow key={item.id}>
<TableCell className="text-gray-500 font-medium text-center">
{startIndex + index}
</TableCell>
<TableCell className="font-mono text-sm">
{item.product_code}
</TableCell>
<TableCell className="font-medium">
{item.product_name}
</TableCell>
<TableCell className="text-gray-500">
{item.category_name || "—"}
</TableCell>
<TableCell>
{item.warehouse_name}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{item.batch_number || "—"}
</TableCell>
<TableCell className="text-sm text-gray-500">
{item.location || "—"}
</TableCell>
<TableCell className={`text-right font-medium ${item.quantity < 0 ? "text-red-600" : ""}`}>
{item.quantity}
</TableCell>
<TableCell className="text-right text-gray-500">
{item.safety_stock !== null
? item.safety_stock
: "—"}
</TableCell>
<TableCell className="text-sm">
{item.expiry_date || "—"}
</TableCell>
<TableCell className="text-center">
<div className="flex flex-wrap items-center justify-center gap-1">
{item.statuses.map(
(status) => {
const config =
statusConfig[
status
];
if (!config)
return null;
return (
<Badge
key={status}
variant="outline"
className={
config.className
}
>
{config.label}
</Badge>
);
}
)}
</div>
</TableCell>
<TableCell className="text-sm text-gray-500">
{item.last_inbound || "—"}
</TableCell>
<TableCell className="text-sm text-gray-500">
{item.last_outbound || "—"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分頁 */}
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" },
]}
className="w-[90px] h-8"
showSearch={false}
/>
<span></span>
</div>
<span className="text-sm text-gray-500">
{inventories.total}
</span>
</div>
<Pagination links={inventories.links} />
</div>
</div>
</AuthenticatedLayout>
);
}