Files
star-erp/resources/js/Pages/Inventory/Report/Index.tsx
sky121113 220478641d
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m3s
feat: 更新庫存報表、銷售匯入及採購單相關功能
2026-02-10 17:18:59 +08:00

609 lines
33 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, useCallback } from "react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import {
Download,
Calendar,
Filter,
Package,
RotateCcw,
FileSpreadsheet,
ArrowUpFromLine,
ArrowDownToLine,
ArrowRightLeft,
TrendingUp,
ArrowUpDown,
ArrowUp,
ArrowDown
} from 'lucide-react';
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { getDateRange } from "@/utils/format";
import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Can } from "@/Components/Permission/Can";
import { PageProps } from "@/types/global";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/Components/ui/tooltip";
interface ReportData {
product_code: string;
product_name: string;
category_name: string;
product_id: number;
inbound_qty: number;
outbound_qty: number;
transfer_in_qty: number;
transfer_out_qty: number;
adjust_qty: number;
net_change: number;
}
interface SummaryData {
total_inbound: number;
total_outbound: number;
total_transfer_in: number;
total_transfer_out: number;
total_adjust: number;
total_net_change: number;
}
interface InventoryReportProps extends PageProps {
reportData: {
data: ReportData[];
links: any[];
total: number;
from: number;
to: number;
current_page: number;
};
summary: SummaryData;
warehouses: { id: number; name: string }[];
categories: { id: number; name: string }[];
filters: {
date_from: string;
date_to: string;
warehouse_id: string;
category_id: string;
search: string;
per_page?: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
};
}
export default function InventoryReportIndex({ reportData, summary, warehouses, categories, filters }: InventoryReportProps) {
const [dateStart, setDateStart] = useState(filters.date_from || "");
const [dateEnd, setDateEnd] = useState(filters.date_to || "");
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || "all");
const [categoryId, setCategoryId] = useState(filters.category_id || "all");
const [search, setSearch] = useState(filters.search || "");
const [perPage, setPerPage] = useState(filters.per_page?.toString() || "10");
// Determine initial range type based on date pairs
const getInitialRangeType = () => {
const { start: todayS, end: todayE } = getDateRange('today');
const { start: yestS, end: yestE } = getDateRange('yesterday');
const { start: weekS, end: weekE } = getDateRange('this_week');
const { start: monthS, end: monthE } = getDateRange('this_month');
const { start: lastMS, end: lastME } = getDateRange('last_month');
const fS = filters.date_from || "";
const fE = filters.date_to || "";
if (fS === todayS && fE === todayE) return "today";
if (fS === yestS && fE === yestE) return "yesterday";
if (fS === weekS && fE === weekE) return "this_week";
if (fS === monthS && fE === monthE) return "this_month";
if (fS === lastMS && fE === lastME) return "last_month";
return "custom";
};
const [dateRangeType, setDateRangeType] = useState(getInitialRangeType());
const handleDateRangeChange = (type: string) => {
setDateRangeType(type);
if (type === "custom") return;
const { start, end } = getDateRange(type);
setDateStart(start);
setDateEnd(end);
};
const handleFilter = useCallback(() => {
router.get(
route("inventory.report.index"),
{
date_from: dateStart,
date_to: dateEnd,
warehouse_id: warehouseId === "all" ? "" : warehouseId,
category_id: categoryId === "all" ? "" : categoryId,
search: search,
per_page: perPage,
},
{ preserveState: true, preserveScroll: true }
);
}, [dateStart, dateEnd, warehouseId, categoryId, search, perPage]);
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route("inventory.report.index"),
{
date_from: dateStart,
date_to: dateEnd,
warehouse_id: warehouseId === "all" ? "" : warehouseId,
category_id: categoryId === "all" ? "" : categoryId,
search: search,
per_page: value,
},
{ preserveState: true, preserveScroll: true }
);
};
const handleClearFilters = () => {
// Service defaults: -7 days to today.
// Let's just clear params and let backend decide or set explicitly.
// Or simply reset to "daily" and "all"
setWarehouseId("all");
setCategoryId("all");
setSearch("");
setDateStart(""); // Will trigger service default
setDateEnd("");
setDateRangeType("custom");
setPerPage("10");
router.get(route("inventory.report.index"));
};
const handleExport = () => {
const query: any = {
date_from: dateStart,
date_to: dateEnd,
warehouse_id: warehouseId === "all" ? "" : warehouseId,
category_id: categoryId === "all" ? "" : categoryId,
search: search,
sort_by: filters.sort_by,
sort_order: filters.sort_order,
};
window.location.href = route("inventory.report.export", query);
};
const handleSort = (field: string) => {
let newSortBy: string | undefined = field;
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
if (filters.sort_by === field) {
if (filters.sort_order === 'asc') {
newSortOrder = 'desc';
} else {
newSortBy = undefined;
newSortOrder = undefined;
}
}
router.get(
route("inventory.report.index"),
{
date_from: dateStart,
date_to: dateEnd,
warehouse_id: warehouseId === "all" ? "" : warehouseId,
category_id: categoryId === "all" ? "" : categoryId,
search: search,
per_page: perPage,
sort_by: newSortBy,
sort_order: newSortOrder,
},
{ preserveState: true, preserveScroll: true }
);
};
const SortIcon = ({ field }: { field: string }) => {
if (filters.sort_by !== field) {
return <ArrowUpDown className="h-4 w-4 text-gray-300 ml-1" />;
}
if (filters.sort_order === "asc") {
return <ArrowUp className="h-4 w-4 text-primary-main ml-1" />;
}
return <ArrowDown className="h-4 w-4 text-primary-main ml-1" />;
};
return (
<AuthenticatedLayout breadcrumbs={[{ label: "報表管理", href: "#" }, { label: "庫存報表", href: route("inventory.report.index"), isPage: true }]}>
<Head title="庫存報表" />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<FileSpreadsheet className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
{filters.date_from && filters.date_to ? (
<><span className="font-medium text-gray-700">{filters.date_from}</span> <span className="font-medium text-gray-700">{filters.date_to}</span></>
) : (
<span className="font-medium text-gray-700"></span>
)}
</p>
</div>
<Can permission="inventory_report.export">
<Button
onClick={handleExport}
variant="outline"
className="button-outlined-primary gap-2"
>
<Download className="h-4 w-4" />
Excel
</Button>
</Can>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5 mb-6">
<div className="space-y-4">
{/* Top Config: Date Range & Quick Buttons */}
<div className="flex flex-col lg:flex-row gap-4 lg:items-end">
<div className="flex-none space-y-2">
<Label className="text-xs font-medium text-grey-2"></Label>
<div className="flex flex-wrap gap-2">
{[
{ label: "今日", value: "today" },
{ label: "昨日", value: "yesterday" },
{ label: "本週", value: "this_week" },
{ label: "本月", value: "this_month" },
{ label: "上月", value: "last_month" },
].map((opt) => (
<Button
key={opt.value}
size="sm"
onClick={() => handleDateRangeChange(opt.value)}
className={
dateRangeType === opt.value
? 'button-filled-primary h-9 px-4 shadow-sm'
: 'button-outlined-primary h-9 px-4 bg-white'
}
>
{opt.label}
</Button>
))}
</div>
</div>
{/* Date Inputs */}
<div className="w-full lg:flex-1">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={dateStart}
onChange={(e) => {
setDateStart(e.target.value);
setDateRangeType('custom');
}}
className="pl-9 block w-full h-9 bg-white"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={dateEnd}
onChange={(e) => {
setDateEnd(e.target.value);
setDateRangeType('custom');
}}
className="pl-9 block w-full h-9 bg-white"
/>
</div>
</div>
</div>
</div>
</div>
{/* Detailed Filters row */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
{/* Warehouse & Category */}
<div className="md:col-span-3 space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<SearchableSelect
value={warehouseId}
onValueChange={setWarehouseId}
options={[{ label: "全部倉庫", value: "all" }, ...warehouses.map(w => ({ label: w.name, value: w.id.toString() }))]}
className="w-full h-9"
placeholder="選擇倉庫..."
/>
</div>
<div className="md:col-span-3 space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<SearchableSelect
value={categoryId}
onValueChange={setCategoryId}
options={[{ label: "全部分類", value: "all" }, ...categories.map(c => ({ label: c.name, value: c.id.toString() }))]}
className="w-full h-9"
placeholder="選擇分類..."
/>
</div>
{/* Search */}
<div className="md:col-span-3 space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<Input
placeholder="搜尋商品..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 bg-white"
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
/>
</div>
{/* Action Buttons Integrated */}
<div className="md:col-span-3 flex items-center gap-2">
<Button
variant="outline"
onClick={handleClearFilters}
className="flex-1 items-center gap-2 button-outlined-primary h-9"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
onClick={handleFilter}
className="flex-1 button-filled-primary h-9 gap-2"
>
<Filter className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
{/* Summary Cards */}
<TooltipProvider>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3 mb-6">
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-emerald-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
<ArrowDownToLine className="h-4 w-4 text-emerald-500 shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-xs text-gray-500 font-medium shrink-0"></span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-lg font-bold text-gray-900 truncate cursor-help">{Number(summary?.total_inbound || 0).toLocaleString()}</span>
</TooltipTrigger>
<TooltipContent>
<p>{Number(summary?.total_inbound || 0).toLocaleString()}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-red-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
<ArrowUpFromLine className="h-4 w-4 text-red-500 shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-xs text-gray-500 font-medium shrink-0"></span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-lg font-bold text-gray-900 truncate cursor-help">{Number(summary?.total_outbound || 0).toLocaleString()}</span>
</TooltipTrigger>
<TooltipContent>
<p>{Number(summary?.total_outbound || 0).toLocaleString()}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-cyan-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
<ArrowDownToLine className="h-4 w-4 text-cyan-500 shrink-0 rotate-180" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-xs text-gray-500 font-medium shrink-0">調</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-lg font-bold text-gray-900 truncate cursor-help">{Number(summary?.total_transfer_in || 0).toLocaleString()}</span>
</TooltipTrigger>
<TooltipContent>
<p>{Number(summary?.total_transfer_in || 0).toLocaleString()}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-orange-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
<ArrowUpFromLine className="h-4 w-4 text-orange-500 shrink-0 rotate-180" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-xs text-gray-500 font-medium shrink-0">調</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-lg font-bold text-gray-900 truncate cursor-help">{Number(summary?.total_transfer_out || 0).toLocaleString()}</span>
</TooltipTrigger>
<TooltipContent>
<p>{Number(summary?.total_transfer_out || 0).toLocaleString()}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-blue-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
<ArrowRightLeft className="h-4 w-4 text-blue-500 shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-xs text-gray-500 font-medium shrink-0">調</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-lg font-bold text-gray-900 truncate cursor-help">{Number(summary?.total_adjust || 0).toLocaleString()}</span>
</TooltipTrigger>
<TooltipContent>
<p>{Number(summary?.total_adjust || 0).toLocaleString()}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-gray-700 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
<TrendingUp className="h-4 w-4 text-gray-700 shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-xs text-gray-500 font-medium shrink-0"></span>
<Tooltip>
<TooltipTrigger asChild>
<span className={`text-lg font-bold truncate cursor-help ${summary?.total_net_change >= 0 ? "text-emerald-600" : "text-red-600"}`}>
{summary?.total_net_change > 0 ? "+" : ""}{Number(summary?.total_net_change || 0).toLocaleString()}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{summary?.total_net_change > 0 ? "+" : ""}{Number(summary?.total_net_change || 0).toLocaleString()}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</TooltipProvider>
{/* Results Table */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="text-right w-[100px] text-emerald-600 cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('inbound_qty')}>
<div className="flex items-center justify-end"> <SortIcon field="inbound_qty" /></div>
</TableHead>
<TableHead className="text-right w-[100px] text-red-600 cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('outbound_qty')}>
<div className="flex items-center justify-end"> <SortIcon field="outbound_qty" /></div>
</TableHead>
<TableHead className="text-right w-[100px] text-cyan-600 cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('transfer_in_qty')}>
<div className="flex items-center justify-end">調 <SortIcon field="transfer_in_qty" /></div>
</TableHead>
<TableHead className="text-right w-[100px] text-orange-600 cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('transfer_out_qty')}>
<div className="flex items-center justify-end">調 <SortIcon field="transfer_out_qty" /></div>
</TableHead>
<TableHead className="text-right w-[100px] text-blue-600 cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('adjust_qty')}>
<div className="flex items-center justify-end">調 <SortIcon field="adjust_qty" /></div>
</TableHead>
<TableHead className="text-right w-[100px] cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('net_change')}>
<div className="flex items-center justify-end"> <SortIcon field="net_change" /></div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reportData.data.length === 0 ? (
<TableRow>
<TableCell colSpan={9}>
<div className="flex flex-col items-center justify-center space-y-2 py-8 text-gray-400">
<Package className="h-10 w-10 opacity-20" />
<p></p>
</div>
</TableCell>
</TableRow>
) : (
reportData.data.map((row) => (
<TableRow key={row.product_id} className="hover:bg-gray-50/50 transition-colors">
<TableCell className="font-medium">
<Link
href={route('inventory.report.show', {
product: row.product_id,
date_from: filters.date_from,
date_to: filters.date_to,
warehouse_id: filters.warehouse_id,
// 以下為返回時恢復報表狀態用
category_id: filters.category_id,
search: filters.search,
per_page: filters.per_page,
report_page: reportData.current_page,
})}
className="text-primary hover:underline hover:text-primary/80 transition-colors"
>
{row.product_code}
</Link>
</TableCell>
<TableCell>
<Link
href={route('inventory.report.show', {
product: row.product_id,
date_from: filters.date_from,
date_to: filters.date_to,
warehouse_id: filters.warehouse_id,
category_id: filters.category_id,
search: filters.search,
per_page: filters.per_page,
report_page: reportData.current_page,
})}
className="text-gray-900 hover:text-primary transition-colors font-medium"
>
{row.product_name}
</Link>
</TableCell>
<TableCell className="text-gray-500">{row.category_name || '-'}</TableCell>
<TableCell className="text-right text-emerald-600 font-medium">
{row.inbound_qty > 0 ? `+${row.inbound_qty}` : "-"}
</TableCell>
<TableCell className="text-right text-red-600 font-medium">
{row.outbound_qty > 0 ? `-${row.outbound_qty}` : "-"}
</TableCell>
<TableCell className="text-right text-cyan-600 font-medium">
{row.transfer_in_qty > 0 ? `+${row.transfer_in_qty}` : "-"}
</TableCell>
<TableCell className="text-right text-orange-600 font-medium">
{row.transfer_out_qty > 0 ? `-${row.transfer_out_qty}` : "-"}
</TableCell>
<TableCell className="text-right text-blue-600 font-medium">
{row.adjust_qty !== 0 ? (row.adjust_qty > 0 ? `+${row.adjust_qty}` : row.adjust_qty) : "-"}
</TableCell>
<TableCell className={`text-right font-bold ${Number(row.net_change) >= 0 ? 'text-gray-900' : 'text-red-500'}`}>
{Number(row.net_change) > 0 ? `+${row.net_change}` : row.net_change}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination Footer */}
<div className="mt-6 flex flex-col sm:flex-row items-start sm:items-center justify-between 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-[100px] h-8"
showSearch={false}
/>
<span></span>
</div>
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
<Pagination links={reportData.links} />
</div>
</div>
</div>
</AuthenticatedLayout>
);
}