first commit
This commit is contained in:
176
resources/js/Components/PurchaseOrder/DateFilter.tsx
Normal file
176
resources/js/Components/PurchaseOrder/DateFilter.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 日期篩選器元件
|
||||
* 支援快捷日期範圍選項和自定義日期範圍
|
||||
*/
|
||||
|
||||
import { Calendar } from "lucide-react";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
|
||||
export interface DateRange {
|
||||
start: string; // YYYY-MM-DD 格式
|
||||
end: string; // YYYY-MM-DD 格式
|
||||
}
|
||||
|
||||
interface DateFilterProps {
|
||||
dateRange: DateRange | null;
|
||||
onDateRangeChange: (range: DateRange | null) => void;
|
||||
}
|
||||
|
||||
// 格式化日期為 YYYY-MM-DD
|
||||
function formatDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 獲取從今天往前 N 天的日期範圍
|
||||
function getDateRange(days: number): DateRange {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - days);
|
||||
|
||||
return {
|
||||
start: formatDate(start),
|
||||
end: formatDate(end),
|
||||
};
|
||||
}
|
||||
|
||||
// 獲取本月的日期範圍
|
||||
function getCurrentMonth(): DateRange {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
return {
|
||||
start: formatDate(start),
|
||||
end: formatDate(end),
|
||||
};
|
||||
}
|
||||
|
||||
// 獲取上月的日期範圍
|
||||
function getLastMonth(): DateRange {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||
|
||||
return {
|
||||
start: formatDate(start),
|
||||
end: formatDate(end),
|
||||
};
|
||||
}
|
||||
|
||||
// 快捷日期選項
|
||||
const DATE_SHORTCUTS = [
|
||||
{ label: "今天", getValue: () => getDateRange(0) },
|
||||
{ label: "最近7天", getValue: () => getDateRange(7) },
|
||||
{ label: "最近30天", getValue: () => getDateRange(30) },
|
||||
{ label: "本月", getValue: () => getCurrentMonth() },
|
||||
{ label: "上月", getValue: () => getLastMonth() },
|
||||
];
|
||||
|
||||
export function DateFilter({ dateRange, onDateRangeChange }: DateFilterProps) {
|
||||
const handleStartDateChange = (value: string) => {
|
||||
if (!value) {
|
||||
onDateRangeChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onDateRangeChange({
|
||||
start: value,
|
||||
end: dateRange?.end || value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEndDateChange = (value: string) => {
|
||||
if (!value) {
|
||||
onDateRangeChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onDateRangeChange({
|
||||
start: dateRange?.start || value,
|
||||
end: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleShortcutClick = (getValue: () => DateRange) => {
|
||||
onDateRangeChange(getValue());
|
||||
};
|
||||
|
||||
const handleClearClick = () => {
|
||||
onDateRangeChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 標題 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-gray-500" />
|
||||
<Label className="font-semibold text-gray-700">建立日期範圍</Label>
|
||||
</div>
|
||||
|
||||
{/* 快捷選項 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DATE_SHORTCUTS.map((shortcut) => (
|
||||
<Button
|
||||
key={shortcut.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleShortcutClick(shortcut.getValue)}
|
||||
className="button-outlined-primary border-gray-200"
|
||||
>
|
||||
{shortcut.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 自定義日期範圍 */}
|
||||
<div className="space-y-4 bg-gray-50/50 p-4 rounded-lg border border-dashed border-gray-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* 開始日期 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="start-date" className="text-sm text-gray-500">
|
||||
開始日期
|
||||
</Label>
|
||||
<Input
|
||||
id="start-date"
|
||||
type="date"
|
||||
value={dateRange?.start || ""}
|
||||
onChange={(e) => handleStartDateChange(e.target.value)}
|
||||
className="border-gray-200 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 結束日期 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="end-date" className="text-sm text-gray-500">
|
||||
結束日期
|
||||
</Label>
|
||||
<Input
|
||||
id="end-date"
|
||||
type="date"
|
||||
value={dateRange?.end || ""}
|
||||
onChange={(e) => handleEndDateChange(e.target.value)}
|
||||
className="border-gray-200 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 清除按鈕 */}
|
||||
{dateRange && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearClick}
|
||||
className="w-full button-outlined-primary border-gray-200"
|
||||
>
|
||||
清除日期篩選
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Edit, Eye } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Link } from "@inertiajs/react";
|
||||
import type { PurchaseOrder } from "@/types/purchase-order";
|
||||
|
||||
export function PurchaseOrderActions({
|
||||
order,
|
||||
}: { order: PurchaseOrder }) {
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={`/purchase-orders/${order.id}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary h-8 w-8 p-0"
|
||||
title="查看採購單"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/purchase-orders/${order.id}/edit`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary h-8 w-8 p-0"
|
||||
title="編輯採購單"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
resources/js/Components/PurchaseOrder/PurchaseOrderFilters.tsx
Normal file
135
resources/js/Components/PurchaseOrder/PurchaseOrderFilters.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 採購單篩選器元件
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Search, X, Filter, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { DateFilter, type DateRange } from "./DateFilter";
|
||||
|
||||
interface PurchaseOrderFiltersProps {
|
||||
searchQuery: string;
|
||||
statusFilter: string;
|
||||
requesterFilter: string;
|
||||
warehouses: { id: string | number; name: string }[];
|
||||
dateRange: DateRange | null;
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onRequesterChange: (value: string) => void;
|
||||
onDateRangeChange: (range: DateRange | null) => void;
|
||||
onClearFilters: () => void;
|
||||
hasActiveFilters: boolean;
|
||||
}
|
||||
|
||||
export function PurchaseOrderFilters({
|
||||
searchQuery,
|
||||
statusFilter,
|
||||
requesterFilter,
|
||||
warehouses,
|
||||
dateRange,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
onRequesterChange,
|
||||
onDateRangeChange,
|
||||
onClearFilters,
|
||||
hasActiveFilters,
|
||||
}: PurchaseOrderFiltersProps) {
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border shadow-sm p-4 space-y-4">
|
||||
{/* 主要篩選列 */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* 搜尋框 */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
placeholder="搜尋採購單編號"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="h-10 pl-10 border-gray-200 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 快速篩選區 */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* 狀態篩選 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-gray-400 hidden sm:block" />
|
||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
||||
<SelectTrigger className="w-[160px] h-10 border-gray-200">
|
||||
<SelectValue placeholder="全部狀態" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部狀態</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="pending">待審核</SelectItem>
|
||||
<SelectItem value="processing">處理中</SelectItem>
|
||||
<SelectItem value="shipping">運送中</SelectItem>
|
||||
<SelectItem value="confirming">待確認</SelectItem>
|
||||
<SelectItem value="completed">已完成</SelectItem>
|
||||
<SelectItem value="cancelled">已取消</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 倉庫篩選 */}
|
||||
<Select value={requesterFilter} onValueChange={onRequesterChange}>
|
||||
<SelectTrigger className="w-[180px] h-10 border-gray-200">
|
||||
<SelectValue placeholder="全部倉庫" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部倉庫</SelectItem>
|
||||
{warehouses.map((warehouse) => (
|
||||
<SelectItem key={warehouse.id} value={String(warehouse.id)}>
|
||||
{warehouse.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 進階篩選按鈕 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
className="gap-2 button-outlined-primary h-10 border-gray-200"
|
||||
>
|
||||
{showAdvancedFilters ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">進階篩選</span>
|
||||
</Button>
|
||||
|
||||
{/* 清除篩選按鈕 */}
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClearFilters}
|
||||
className="gap-2 button-outlined-primary h-10 border-gray-200"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">清除篩選</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 進階篩選區 */}
|
||||
{showAdvancedFilters && (
|
||||
<div className="pt-4 border-t border-gray-100">
|
||||
<DateFilter dateRange={dateRange} onDateRangeChange={onDateRangeChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 採購單商品表格元件
|
||||
*/
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import type { PurchaseOrderItem, Supplier } from "@/types/purchase-order";
|
||||
import { isPriceAlert, formatCurrency } from "@/utils/purchase-order";
|
||||
|
||||
interface PurchaseOrderItemsTableProps {
|
||||
items: PurchaseOrderItem[];
|
||||
supplier?: Supplier;
|
||||
isReadOnly?: boolean;
|
||||
isDisabled?: boolean;
|
||||
onAddItem?: () => void;
|
||||
onRemoveItem?: (index: number) => void;
|
||||
onItemChange?: (index: number, field: keyof PurchaseOrderItem, value: string | number) => void;
|
||||
}
|
||||
|
||||
export function PurchaseOrderItemsTable({
|
||||
items,
|
||||
supplier,
|
||||
isReadOnly = false,
|
||||
isDisabled = false,
|
||||
onRemoveItem,
|
||||
onItemChange,
|
||||
}: PurchaseOrderItemsTableProps) {
|
||||
return (
|
||||
<div className={`border rounded-lg overflow-hidden ${isDisabled ? "opacity-50 pointer-events-none grayscale" : ""}`}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableHead className="w-[30%] text-left">商品名稱</TableHead>
|
||||
<TableHead className="w-[15%] text-left">數量</TableHead>
|
||||
<TableHead className="w-[10%] text-left">單位</TableHead>
|
||||
<TableHead className="w-[20%] text-left">預估單價</TableHead>
|
||||
<TableHead className="w-[20%] text-left">小計</TableHead>
|
||||
{!isReadOnly && <TableHead className="w-[5%]"></TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={isReadOnly ? 5 : 6}
|
||||
className="text-center text-gray-400 py-12 italic"
|
||||
>
|
||||
{isDisabled ? "請先選擇供應商後才能新增商品" : "尚未新增任何商品項"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
{/* 商品選擇 */}
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="font-medium">{item.productName}</span>
|
||||
) : (
|
||||
<Select
|
||||
value={item.productId}
|
||||
onValueChange={(value) =>
|
||||
onItemChange?.(index, "productId", value)
|
||||
}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger className="h-10 border-gray-200">
|
||||
<SelectValue placeholder="選擇商品" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supplier?.commonProducts.map((product) => (
|
||||
<SelectItem key={product.productId} value={product.productId}>
|
||||
{product.productName}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(!supplier || supplier.commonProducts.length === 0) && (
|
||||
<div className="p-2 text-sm text-gray-400 text-center">無可用商品</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 數量 */}
|
||||
<TableCell className="text-left">
|
||||
{isReadOnly ? (
|
||||
<span>{Math.floor(item.quantity)}</span>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={item.quantity === 0 ? "" : Math.floor(item.quantity)}
|
||||
onChange={(e) =>
|
||||
onItemChange?.(index, "quantity", Math.floor(Number(e.target.value)))
|
||||
}
|
||||
disabled={isDisabled}
|
||||
className="h-10 text-left border-gray-200 w-24"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 單位 */}
|
||||
<TableCell>
|
||||
<span className="text-gray-500 font-medium">{item.unit || "-"}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* 單價 */}
|
||||
<TableCell className="text-left">
|
||||
{isReadOnly ? (
|
||||
<span className="font-medium text-gray-900">{formatCurrency(item.unitPrice)}</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={item.unitPrice || ""}
|
||||
onChange={(e) =>
|
||||
onItemChange?.(index, "unitPrice", Number(e.target.value))
|
||||
}
|
||||
disabled={isDisabled}
|
||||
className={`h-10 text-left w-32 ${isPriceAlert(item.unitPrice, item.previousPrice)
|
||||
? "border-amber-400 bg-amber-50 focus-visible:ring-amber-500"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
/>
|
||||
{isPriceAlert(item.unitPrice, item.previousPrice) && (
|
||||
<p className="text-[10px] text-amber-600 font-medium animate-pulse">
|
||||
⚠️ 高於上次: {formatCurrency(item.previousPrice || 0)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 小計 */}
|
||||
<TableCell className="text-left">
|
||||
<span className="font-bold text-primary">{formatCurrency(item.subtotal)}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* 刪除按鈕 */}
|
||||
{!isReadOnly && onRemoveItem && (
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemoveItem(index)}
|
||||
className="h-8 w-8 text-gray-300 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 採購單狀態標籤組件
|
||||
*/
|
||||
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { PurchaseOrderStatus } from "@/types/purchase-order";
|
||||
|
||||
interface PurchaseOrderStatusBadgeProps {
|
||||
status: PurchaseOrderStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PurchaseOrderStatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: PurchaseOrderStatusBadgeProps) {
|
||||
const getStatusConfig = (status: PurchaseOrderStatus) => {
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return { label: "草稿", className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
case "pending":
|
||||
return { label: "待審核", className: "bg-blue-100 text-blue-700 border-blue-200" };
|
||||
case "processing":
|
||||
return { label: "處理中", className: "bg-yellow-100 text-yellow-700 border-yellow-200" };
|
||||
case "shipping":
|
||||
return { label: "運送中", className: "bg-purple-100 text-purple-700 border-purple-200" };
|
||||
case "confirming":
|
||||
return { label: "待確認", className: "bg-orange-100 text-orange-700 border-orange-200" };
|
||||
case "completed":
|
||||
return { label: "已完成", className: "bg-green-100 text-green-700 border-green-200" };
|
||||
case "cancelled":
|
||||
return { label: "已取消", className: "bg-red-100 text-red-700 border-red-200" };
|
||||
default:
|
||||
return { label: "未知", className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${config.className} ${className} font-medium px-2.5 py-0.5 rounded-full`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
230
resources/js/Components/PurchaseOrder/PurchaseOrderTable.tsx
Normal file
230
resources/js/Components/PurchaseOrder/PurchaseOrderTable.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 採購單列表表格
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { PurchaseOrderActions } from "./PurchaseOrderActions";
|
||||
import PurchaseOrderStatusBadge from "./PurchaseOrderStatusBadge";
|
||||
import CopyButton from "@/Components/shared/CopyButton";
|
||||
import type { PurchaseOrder } from "@/types/purchase-order";
|
||||
import { formatCurrency, formatDateTime } from "@/utils/format";
|
||||
import { STATUS_CONFIG } from "@/constants/purchase-order";
|
||||
|
||||
interface PurchaseOrderTableProps {
|
||||
orders: PurchaseOrder[];
|
||||
}
|
||||
|
||||
type SortField = "poNumber" | "warehouse_name" | "supplierName" | "createdAt" | "totalAmount" | "status";
|
||||
type SortDirection = "asc" | "desc" | null;
|
||||
|
||||
export default function PurchaseOrderTable({
|
||||
orders,
|
||||
}: PurchaseOrderTableProps) {
|
||||
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
||||
|
||||
// 處理排序
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
if (sortDirection === "asc") {
|
||||
setSortDirection("desc");
|
||||
} else if (sortDirection === "desc") {
|
||||
setSortDirection(null);
|
||||
setSortField(null);
|
||||
} else {
|
||||
setSortDirection("asc");
|
||||
}
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// 排序後的訂單列表
|
||||
const sortedOrders = useMemo(() => {
|
||||
if (!sortField || !sortDirection) {
|
||||
return orders;
|
||||
}
|
||||
|
||||
return [...orders].sort((a, b) => {
|
||||
let aValue: string | number;
|
||||
let bValue: string | number;
|
||||
|
||||
switch (sortField) {
|
||||
case "poNumber":
|
||||
aValue = a.poNumber;
|
||||
bValue = b.poNumber;
|
||||
break;
|
||||
case "warehouse_name":
|
||||
aValue = a.warehouse_name || "";
|
||||
bValue = b.warehouse_name || "";
|
||||
break;
|
||||
case "supplierName":
|
||||
aValue = a.supplierName;
|
||||
bValue = b.supplierName;
|
||||
break;
|
||||
case "createdAt":
|
||||
aValue = a.createdAt;
|
||||
bValue = b.createdAt;
|
||||
break;
|
||||
case "totalAmount":
|
||||
aValue = a.totalAmount;
|
||||
bValue = b.totalAmount;
|
||||
break;
|
||||
case "status":
|
||||
aValue = STATUS_CONFIG[a.status].label;
|
||||
bValue = STATUS_CONFIG[b.status].label;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return sortDirection === "asc"
|
||||
? aValue.localeCompare(bValue, "zh-TW")
|
||||
: bValue.localeCompare(aValue, "zh-TW");
|
||||
} else {
|
||||
return sortDirection === "asc"
|
||||
? (aValue as number) - (bValue as number)
|
||||
: (bValue as number) - (aValue as number);
|
||||
}
|
||||
});
|
||||
}, [orders, sortField, sortDirection]);
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) {
|
||||
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
if (sortDirection === "asc") {
|
||||
return <ArrowUp className="h-4 w-4 text-primary" />;
|
||||
}
|
||||
if (sortDirection === "desc") {
|
||||
return <ArrowDown className="h-4 w-4 text-primary" />;
|
||||
}
|
||||
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableHead className="w-[50px]">#</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
<button
|
||||
onClick={() => handleSort("poNumber")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors font-semibold"
|
||||
>
|
||||
採購單編號
|
||||
<SortIcon field="poNumber" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[200px]">
|
||||
<button
|
||||
onClick={() => handleSort("warehouse_name")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors font-semibold"
|
||||
>
|
||||
預計入庫倉庫
|
||||
<SortIcon field="warehouse_name" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
<button
|
||||
onClick={() => handleSort("supplierName")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors font-semibold"
|
||||
>
|
||||
供應商
|
||||
<SortIcon field="supplierName" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[150px]">
|
||||
<button
|
||||
onClick={() => handleSort("createdAt")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors font-semibold"
|
||||
>
|
||||
建立日期
|
||||
<SortIcon field="createdAt" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] text-right">
|
||||
<button
|
||||
onClick={() => handleSort("totalAmount")}
|
||||
className="flex items-center gap-2 ml-auto hover:text-foreground transition-colors font-semibold"
|
||||
>
|
||||
總金額
|
||||
<SortIcon field="totalAmount" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
<button
|
||||
onClick={() => handleSort("status")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors font-semibold"
|
||||
>
|
||||
狀態
|
||||
<SortIcon field="status" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-semibold">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedOrders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground py-12">
|
||||
尚無採購單
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedOrders.map((order, index) => (
|
||||
<TableRow key={order.id}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-sm font-medium">{order.poNumber}</span>
|
||||
<CopyButton text={order.poNumber} label="複製單號" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium text-gray-900">{order.warehouse_name}</div>
|
||||
<div className="text-xs text-gray-500">{order.createdBy}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-700">{order.supplierName}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-500">{formatDateTime(order.createdAt)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="font-semibold text-gray-900">{formatCurrency(order.totalAmount)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PurchaseOrderStatusBadge status={order.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PurchaseOrderActions
|
||||
order={order}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
resources/js/Components/PurchaseOrder/StatusProgressBar.tsx
Normal file
95
resources/js/Components/PurchaseOrder/StatusProgressBar.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 狀態流程條組件
|
||||
*/
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import type { PurchaseOrderStatus } from "@/types/purchase-order";
|
||||
|
||||
interface StatusProgressBarProps {
|
||||
currentStatus: PurchaseOrderStatus;
|
||||
}
|
||||
|
||||
// 流程步驟定義
|
||||
const FLOW_STEPS: { key: PurchaseOrderStatus | "approved"; label: string }[] = [
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "pending", label: "待審核" },
|
||||
{ key: "processing", label: "處理中" },
|
||||
{ key: "shipping", label: "運送中" },
|
||||
{ key: "confirming", label: "待確認" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
];
|
||||
|
||||
export function StatusProgressBar({ currentStatus }: StatusProgressBarProps) {
|
||||
// 對於 cancelled 狀態,進度條通常不顯示或顯示特殊樣式,這裡我們顯示到最後一個有效狀態
|
||||
const effectiveStatus = currentStatus === "cancelled" ? "pending" : currentStatus;
|
||||
|
||||
// 找到當前狀態在流程中的位置
|
||||
const currentIndex = FLOW_STEPS.findIndex((step) => step.key === effectiveStatus);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border shadow-sm p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-6">採購單處理進度</h3>
|
||||
<div className="relative px-4">
|
||||
{/* 進度條背景 */}
|
||||
<div className="absolute top-5 left-8 right-8 h-0.5 bg-gray-100" />
|
||||
|
||||
{/* 進度條進度 */}
|
||||
{currentIndex >= 0 && (
|
||||
<div
|
||||
className="absolute top-5 left-8 h-0.5 bg-primary transition-all duration-500"
|
||||
style={{
|
||||
width: `${(currentIndex / (FLOW_STEPS.length - 1)) * 100}%`,
|
||||
maxWidth: "calc(100% - 4rem)"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 步驟標記 */}
|
||||
<div className="relative flex justify-between">
|
||||
{FLOW_STEPS.map((step, index) => {
|
||||
const isCompleted = index < currentIndex;
|
||||
const isCurrent = index === currentIndex;
|
||||
// 如果當前是 cancelled,且我們正在渲染 pending 步驟,可以加點提示
|
||||
const isRejectedAtThisStep = currentStatus === "cancelled" && step.key === "pending";
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex flex-col items-center flex-1">
|
||||
{/* 圓點 */}
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 z-10 transition-all duration-300 ${isRejectedAtThisStep
|
||||
? "bg-red-500 border-red-500 text-white"
|
||||
: isCompleted
|
||||
? "bg-primary border-primary text-white"
|
||||
: isCurrent
|
||||
? "bg-white border-primary text-primary ring-4 ring-primary/10 font-bold"
|
||||
: "bg-white border-gray-200 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{isCompleted && !isRejectedAtThisStep ? (
|
||||
<Check className="h-5 w-5" />
|
||||
) : (
|
||||
<span className="text-sm">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 標籤 */}
|
||||
<div className="mt-3 text-center">
|
||||
<p
|
||||
className={`text-xs whitespace-nowrap transition-colors ${isRejectedAtThisStep
|
||||
? "text-red-600 font-bold"
|
||||
: isCompleted || isCurrent
|
||||
? "text-gray-900 font-bold"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{isRejectedAtThisStep ? "已取消" : step.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user