feat: 統一採購單與操作紀錄 UI、增強各模組操作紀錄功能
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 59s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

- 統一採購單篩選列與表單樣式 (移除舊元件、標準化 Input)
- 增強操作紀錄功能 (加入篩選、快照、詳細異動比對)
- 統一刪除確認視窗與按鈕樣式
- 修復庫存編輯頁面樣式
- 實作採購單品項異動紀錄
- 實作角色分配異動紀錄
- 擴充供應商與倉庫模組紀錄
This commit is contained in:
2026-01-19 17:07:45 +08:00
parent 5c4693577a
commit 7367577f6a
16 changed files with 541 additions and 444 deletions

View File

@@ -1,176 +0,0 @@
/**
* 日期篩選器元件
* 支援快捷日期範圍選項和自定義日期範圍
*/
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>
);
}

View File

@@ -1,135 +0,0 @@
/**
* 採購單篩選器元件
*/
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>
);
}

View File

@@ -105,7 +105,7 @@ export function PurchaseOrderItemsTable({
onItemChange?.(index, "quantity", Math.floor(Number(e.target.value)))
}
disabled={isDisabled}
className="h-10 text-left border-gray-200 w-24"
className="text-left w-24"
/>
)}
</TableCell>
@@ -161,11 +161,11 @@ export function PurchaseOrderItemsTable({
onItemChange?.(index, "subtotal", Number(e.target.value))
}
disabled={isDisabled}
className={`h-10 text-left w-32 ${
className={`text-left w-32 ${
// 如果有數量但沒有金額,顯示錯誤樣式
item.quantity > 0 && (!item.subtotal || item.subtotal <= 0)
? "border-red-400 bg-red-50 focus-visible:ring-red-500"
: "border-gray-200"
: ""
}`}
/>
{/* 錯誤提示 */}