此日期區間內無支出紀錄
@@ -333,7 +343,7 @@ export default function AccountingReport({ records, summary, filters }: PageProp
@@ -348,11 +358,47 @@ export default function AccountingReport({ records, summary, filters }: PageProp
{record.item}
- {record.invoice_number && (
- 發票:{record.invoice_number}
+ {(record.invoice_number || record.invoice_date) && (
+
+ 發票:{record.invoice_number || '-'}
+ {record.invoice_date && ` (${record.invoice_date})`}
+
+ )}
+ {record.remarks && (
+
+ 備註:{record.remarks}
+
)}
+
+
+ {record.payment_method || '-'}
+ {record.payment_note && (
+
+ {record.payment_note}
+
+ )}
+
+
+
+ {record.status === 'paid' ? (
+ 已付款
+ ) : record.status === 'pending' ? (
+ 待付款
+ ) : record.status === 'overdue' ? (
+ 已逾期
+ ) : record.status === 'draft' ? (
+ 草稿
+ ) : record.status === 'approved' ? (
+ 已核准
+ ) : (
+ {record.status || '-'}
+ )}
+
+
+ {record.tax_amount ? `$ ${Number(record.tax_amount).toLocaleString()}` : '-'}
+
$ {Number(record.amount).toLocaleString()}
diff --git a/resources/js/Pages/Admin/ActivityLog/Index.tsx b/resources/js/Pages/Admin/ActivityLog/Index.tsx
index ef9962a..ca0011c 100644
--- a/resources/js/Pages/Admin/ActivityLog/Index.tsx
+++ b/resources/js/Pages/Admin/ActivityLog/Index.tsx
@@ -318,10 +318,10 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
from={activities.from}
/>
-
-
+
+
- 每頁顯示
+ 每頁顯示
- 筆
+ 筆
-
共 {activities.total} 筆資料
+
共 {activities.total} 筆資料
-
diff --git a/resources/js/Pages/Production/Create.tsx b/resources/js/Pages/Production/Create.tsx
index 3b819db..6e83959 100644
--- a/resources/js/Pages/Production/Create.tsx
+++ b/resources/js/Pages/Production/Create.tsx
@@ -96,11 +96,11 @@ export default function Create({ products, warehouses }: Props) {
const [recipes, setRecipes] = useState
([]);
const [selectedRecipeId, setSelectedRecipeId] = useState("");
- const { data, setData, processing, errors } = useForm({
+ // 提交表單
+ const { data, setData, processing, errors, setError, clearErrors } = useForm({
product_id: "",
warehouse_id: "",
output_quantity: "",
- // 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
remark: "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
@@ -108,7 +108,6 @@ export default function Create({ products, warehouses }: Props) {
// 獲取特定商品在各倉庫的庫存分佈
const fetchProductInventories = async (productId: string) => {
if (!productId) return;
- // 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
if (loadingProducts[productId]) return;
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
@@ -159,7 +158,6 @@ export default function Create({ products, warehouses }: Props) {
item.unit_id = "";
item.ui_input_quantity = "";
item.ui_selected_unit = "base";
- // 清除 cache 資訊
delete item.ui_product_name;
delete item.ui_batch_number;
delete item.ui_available_qty;
@@ -180,21 +178,13 @@ export default function Create({ products, warehouses }: Props) {
}
}
- // 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量)
if (field === 'ui_warehouse_id') {
item.inventory_id = "";
- // 不重置數量
- // item.quantity_used = "";
- // item.ui_input_quantity = "";
- // item.ui_selected_unit = "base";
-
- // 清除某些 cache
delete item.ui_batch_number;
delete item.ui_available_qty;
delete item.ui_expiry_date;
}
- // 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
if (field === 'inventory_id' && value) {
const currentOptions = productInventoryMap[item.ui_product_id] || [];
const inv = currentOptions.find(i => String(i.id) === value);
@@ -203,45 +193,31 @@ export default function Create({ products, warehouses }: Props) {
item.ui_batch_number = inv.batch_number;
item.ui_available_qty = inv.quantity;
item.ui_expiry_date = inv.expiry_date || '';
-
- // 單位與轉換率
item.ui_base_unit_name = inv.unit_name || '';
item.ui_base_unit_id = inv.base_unit_id;
item.ui_large_unit_id = inv.large_unit_id;
item.ui_purchase_unit_id = inv.purchase_unit_id;
item.ui_conversion_rate = inv.conversion_rate || 1;
item.ui_unit_cost = inv.unit_cost || 0;
-
- // 預設單位
item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || '');
-
- // 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方)
if (!item.ui_input_quantity) {
item.ui_input_quantity = formatQuantity(inv.quantity);
}
}
}
- // 4. 計算最終數量 (Base Quantity)
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
const inputQty = parseFloat(item.ui_input_quantity || '0');
const rate = item.ui_conversion_rate || 1;
-
- if (item.ui_selected_unit === 'large') {
- item.quantity_used = String(inputQty * rate);
- item.unit_id = String(item.ui_base_unit_id || '');
- } else {
- item.quantity_used = String(inputQty);
- item.unit_id = String(item.ui_base_unit_id || '');
- }
+ item.quantity_used = item.ui_selected_unit === 'large' ? String(inputQty * rate) : String(inputQty);
+ item.unit_id = String(item.ui_base_unit_id || '');
}
updated[index] = item;
setBomItems(updated);
};
- // 同步 BOM items 到表單 data
useEffect(() => {
setData('items', bomItems.map(item => ({
inventory_id: Number(item.inventory_id),
@@ -250,33 +226,22 @@ export default function Create({ products, warehouses }: Props) {
})));
}, [bomItems]);
- // 應用配方到表單 (獨立函式)
const applyRecipe = (recipe: any) => {
if (!recipe || !recipe.items) return;
-
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
- // 自動帶入配方標準產量
setData('output_quantity', formatQuantity(yieldQty));
-
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
- const baseQty = parseFloat(item.quantity || "0");
- const calculatedQty = baseQty; // 保持精度
-
- // 若有配方商品,預先載入庫存分佈
- if (item.product_id) {
- fetchProductInventories(String(item.product_id));
- }
-
+ if (item.product_id) fetchProductInventories(String(item.product_id));
return {
inventory_id: "",
- quantity_used: String(calculatedQty),
+ quantity_used: String(item.quantity || "0"),
unit_id: String(item.unit_id),
ui_warehouse_id: "",
ui_product_id: String(item.product_id),
ui_product_name: item.product_name,
ui_batch_number: "",
ui_available_qty: 0,
- ui_input_quantity: formatQuantity(calculatedQty),
+ ui_input_quantity: formatQuantity(item.quantity || "0"),
ui_selected_unit: 'base',
ui_base_unit_name: item.unit_name,
ui_base_unit_id: item.unit_id,
@@ -284,45 +249,30 @@ export default function Create({ products, warehouses }: Props) {
};
});
setBomItems(newBomItems);
-
- toast.success(`已自動載入配方: ${recipe.name}`, {
- description: `標準產量: ${formatQuantity(yieldQty)} 份`
- });
+ toast.success(`已自動載入配方: ${recipe.name}`);
};
- // 當手動切換配方時
useEffect(() => {
if (!selectedRecipeId) return;
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
- if (targetRecipe) {
- applyRecipe(targetRecipe);
- }
+ if (targetRecipe) applyRecipe(targetRecipe);
}, [selectedRecipeId]);
- // 自動產生成品批號與載入配方
useEffect(() => {
if (!data.product_id) return;
-
- // 2. 自動載入配方列表
const fetchRecipes = async () => {
try {
- // 改為抓取所有配方
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
const recipesData = await res.json();
-
if (Array.isArray(recipesData) && recipesData.length > 0) {
setRecipes(recipesData);
- // 預設選取最新的 (第一個)
- const latest = recipesData[0];
- setSelectedRecipeId(String(latest.id));
+ setSelectedRecipeId(String(recipesData[0].id));
} else {
- // 若無配方
setRecipes([]);
setSelectedRecipeId("");
- setBomItems([]); // 清空 BOM
+ setBomItems([]);
}
} catch (e) {
- console.error("Failed to fetch recipes", e);
setRecipes([]);
setBomItems([]);
}
@@ -330,61 +280,74 @@ export default function Create({ products, warehouses }: Props) {
fetchRecipes();
}, [data.product_id]);
- // 當生產數量變動時,如果是從配方載入的,則按比例更新用量
+ // 當有驗證錯誤時,自動聚焦到第一個錯誤欄位
useEffect(() => {
- if (bomItems.length > 0 && data.output_quantity) {
- // 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號
- // 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾
- // 但如果是剛載入(inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性
+ const errorKeys = Object.keys(errors);
+ if (errorKeys.length > 0) {
+ // 延遲一下確保 DOM 已更新(例如 BOM 明細行渲染)
+ setTimeout(() => {
+ const firstInvalid = document.querySelector('[aria-invalid="true"]');
+ if (firstInvalid instanceof HTMLElement) {
+ firstInvalid.focus();
+ firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+ }, 100);
}
- }, [data.output_quantity]);
+ }, [errors]);
- // 提交表單
- const submit = (status: 'draft' | 'completed') => {
- // 驗證(簡單前端驗證,完整驗證在後端)
- if (status === 'completed') {
- const missingFields = [];
- if (!data.product_id) missingFields.push('成品商品');
- if (!data.output_quantity) missingFields.push('生產數量');
- if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
- if (bomItems.length === 0) missingFields.push('原物料明細');
+ const submit = (status: 'draft') => {
+ clearErrors();
+ let hasError = false;
- if (missingFields.length > 0) {
- toast.error("請填寫必要欄位", {
- description: `缺漏:${missingFields.join('、')}`
- });
- return;
+ // 草稿建立時也要求必填生產數量與預計入庫倉庫
+ if (!data.product_id) { setError('product_id', '請選擇成品商品'); hasError = true; }
+ if (!data.output_quantity) { setError('output_quantity', '請輸入生產數量'); hasError = true; }
+ if (!selectedWarehouse) { setError('warehouse_id', '請選擇預計入庫倉庫'); hasError = true; }
+ if (bomItems.length === 0) { toast.error("請至少新增一項原物料明細"); hasError = true; }
+
+ // 驗證 BOM 明細
+ bomItems.forEach((item, index) => {
+ if (!item.ui_product_id) {
+ setError(`items.${index}.ui_product_id` as any, '請選擇商品');
+ hasError = true;
+ } else {
+ if (!item.inventory_id) {
+ setError(`items.${index}.inventory_id` as any, '請選擇批號');
+ hasError = true;
+ }
+ if (!item.quantity_used || parseFloat(item.quantity_used) <= 0) {
+ setError(`items.${index}.quantity_used` as any, '請輸入數量');
+ hasError = true;
+ }
}
+ });
+
+ if (hasError) {
+ toast.error("建立失敗,請檢查標單內紅框欄位");
+ return;
}
- // 轉換 BOM items 格式
- const formattedItems = bomItems
- .filter(item => status === 'draft' || (item.inventory_id && item.quantity_used))
- .map(item => ({
- inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null,
- quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0,
- unit_id: item.unit_id ? parseInt(item.unit_id) : null,
- }));
+ const formattedItems = bomItems.map(item => ({
+ inventory_id: parseInt(item.inventory_id),
+ quantity_used: parseFloat(item.quantity_used),
+ unit_id: item.unit_id ? parseInt(item.unit_id) : null,
+ }));
- // 使用 router.post 提交完整資料
router.post(route('production-orders.store'), {
...data,
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
items: formattedItems,
status: status,
}, {
- onError: (errors) => {
- const errorCount = Object.keys(errors).length;
- toast.error("建立失敗,請檢查表單", {
- description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
- });
+ onError: () => {
+ toast.error("建立失敗,請檢查表單");
}
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
- submit('completed');
+ submit('draft');
};
const getBomItemUnitCost = (item: BomItem) => {
@@ -423,7 +386,7 @@ export default function Create({ products, warehouses }: Props) {
-
+
建立生產工單
@@ -431,14 +394,18 @@ export default function Create({ products, warehouses }: Props) {
建立新的生產排程,選擇原物料並記錄產出
-
+
+
+
@@ -458,6 +425,7 @@ export default function Create({ products, warehouses }: Props) {
}))}
placeholder="選擇成品"
className="w-full h-9"
+ aria-invalid={!!errors.product_id}
/>
{errors.product_id &&
{errors.product_id}
}
@@ -493,6 +461,7 @@ export default function Create({ products, warehouses }: Props) {
onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50"
className="h-9 font-mono"
+ aria-invalid={!!errors.output_quantity}
/>
{errors.output_quantity &&
{errors.output_quantity}
}
@@ -508,6 +477,7 @@ export default function Create({ products, warehouses }: Props) {
}))}
placeholder="選擇倉庫"
className="w-full h-9"
+ aria-invalid={!!errors.warehouse_id}
/>
{errors.warehouse_id &&
{errors.warehouse_id}
}
@@ -600,6 +570,7 @@ export default function Create({ products, warehouses }: Props) {
options={productOptions}
placeholder="選擇商品"
className="w-full"
+ aria-invalid={!!errors[`items.${index}.ui_product_id` as any]}
/>
@@ -628,6 +599,7 @@ export default function Create({ products, warehouses }: Props) {
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
className="w-full"
disabled={!item.ui_warehouse_id}
+ aria-invalid={!!errors[`items.${index}.inventory_id` as any]}
/>
{item.inventory_id && (() => {
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
@@ -655,6 +627,7 @@ export default function Create({ products, warehouses }: Props) {
placeholder="0"
className="h-9 text-right"
disabled={!item.inventory_id}
+ aria-invalid={!!errors[`items.${index}.quantity_used` as any]}
/>
diff --git a/resources/js/Pages/Production/Index.tsx b/resources/js/Pages/Production/Index.tsx
index 5a633c3..9ca8101 100644
--- a/resources/js/Pages/Production/Index.tsx
+++ b/resources/js/Pages/Production/Index.tsx
@@ -2,8 +2,9 @@
* 生產工單管理主頁面
*/
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
import { Plus, Factory, Search, Eye, Pencil, Trash2 } from 'lucide-react';
+import { debounce } from "lodash";
import { formatQuantity } from "@/lib/utils";
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
@@ -77,16 +78,25 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
setPerPage(filters.per_page || productionOrders.per_page?.toString() || "10");
}, [filters]);
- const handleFilter = () => {
- router.get(
- route('production-orders.index'),
- {
- search,
- status: status === 'all' ? undefined : status,
- per_page: perPage,
- },
- { preserveState: true, replace: true, preserveScroll: true }
- );
+ const debouncedFilter = useCallback(
+ debounce((params: any) => {
+ router.get(route("production-orders.index"), params, {
+ preserveState: true,
+ replace: true,
+ preserveScroll: true,
+ });
+ }, 300),
+ []
+ );
+
+ const handleSearchChange = (term: string) => {
+ setSearch(term);
+ debouncedFilter({
+ ...filters,
+ search: term,
+ status: status === "all" ? undefined : status,
+ per_page: perPage,
+ });
};
@@ -129,16 +139,12 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
setSearch(e.target.value)}
+ onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10 h-9"
- onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
/>
{search && (