Compare commits

..

11 Commits

Author SHA1 Message Date
3ce96537b3 feat: 標準化全系統數值輸入欄位與擴充商品價格功能
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m0s
1. UI 標準化:
   - 針對全系統數值輸入欄位統一加上 step='any' 以支援小數點。
   - 表格形式 (Table) 的數值輸入欄位統一加上 text-right 靠右對齊。
   - 修正 Components 與 Pages 中所有涉及金額與數量的輸入框。

2. 功能擴充與修正:
   - 擴充 Product 模型與相關 Dialog 以支援多種價格設定。
   - 修正 Inventory/GoodsReceipt/Create.tsx 未使用的變數錯誤。
   - 優化庫存相關頁面的 UI 一致性。

3. 其他:
   - 更新相關的 Type 定義與 Controller 邏輯。
2026-02-05 11:45:08 +08:00
04f3891275 feat: 實作出貨單模組並暫時導向通用製作中頁面,同步優化盤點與調撥功能的活動日誌顯示
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m11s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-02-05 09:33:36 +08:00
4299e985e9 feat: 優化庫存調撥單操作紀錄與 UI 佈局 2026-02-04 17:51:29 +08:00
2eb136d280 feat(inventory): 完善庫存盤調更新與日誌邏輯,新增「無需盤調」狀態判定
1. 修正 AdjustDocController 缺失 update 方法導致的錯誤。
2. 修正 ActivityDetailDialog 前端 map 渲染 undefined 的 TypeError。
3. 優化盤調單「過帳」日誌,現在會同步包含當時的商品明細快照。
4. 實作盤點單「無需盤調」(no_adjust) 自動判定邏輯:
   - 當盤點數量與庫存完全一致時,自動標記為 no_adjust 結案。
   - 更新前端標籤樣式與操作按鈕對應邏輯。
   - 限制 no_adjust 單據不可重複建立盤調單。
5. 統一盤點單與盤調單的日誌配置,優化 ID 轉名稱顯示。
2026-02-04 16:56:08 +08:00
88415505fb docs(skill): 更新操作紀錄實作規範
整合全域 ID 轉名稱邏輯、日誌合併策略以及針對 Collection 修改錯誤的修復方案。
2026-02-04 15:39:05 +08:00
702af0a259 feat(inventory): 重構庫存盤點流程與優化操作日誌
1. 重構盤點流程:實作自動狀態轉換(盤點中/盤點完成)、整合按鈕為「儲存盤點結果」、更名 UI 狀態標籤。
2. 優化操作日誌:
   - 實作全域 ID 轉名稱邏輯(倉庫、使用者)。
   - 合併單次操作的日誌記錄,避免重複產生。
   - 修復日誌產生過程中的 Collection 修改錯誤。
3. 修正 TypeScript lint 錯誤(Index, Show 頁面)。
2026-02-04 15:12:10 +08:00
f4f597e96d fix(inventory): 修復 Controller 語法錯誤並補齊操作記錄 2026-02-04 13:25:49 +08:00
a8b88b3375 feat(inventory): 實作盤點、盤調與調撥操作紀錄,並支援前端本地化顯示 2026-02-04 13:24:33 +08:00
95fdec8a06 feat(procurement): 修正採購單與進貨單日期標籤、狀態與操作紀錄本地化 2026-02-04 13:20:18 +08:00
4ba85ce446 feat(production): 優化生產單 BOM 原物料選取邏輯,支援商品 -> 倉庫 -> 批號連動與 API 分佈查詢 2026-02-04 13:08:05 +08:00
a0c450d229 refactor(role): 重構角色權限選擇介面並新增快速搜尋功能
1. 新增 PermissionSelector 組件,採用 Accordion 折疊式設計
2. 實作全選/取消全選、展開/收合全部功能
3. 新增權限搜尋過濾器,支援自動展開與中文關鍵字搜尋
4. 優化 UI細節:修正邊框顯示、調整全選框位置與邏輯
2026-02-04 11:07:32 +08:00
79 changed files with 3673 additions and 1003 deletions

View File

@@ -1,158 +1,111 @@
--- ---
name: 操作紀錄實作規範 name: 操作紀錄實作規範
description: 規範系統內 Activity Log 的實作標準,包含後端資料過濾、快照策略、與前端顯示邏輯 description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化
--- ---
# 操作紀錄實作規範 # 操作紀錄實作規範 (Activity Logging Skill)
本文件說明如何在開發新功能時,依據系統規範實作 `spatie/laravel-activitylog` 操作紀錄,確保資料儲存效率與前端顯示一致性 本文件定義了 Star ERP 系統中操作紀錄的最高實作標準,旨在確保每筆日誌都具有「高度可讀性」與「單一性」
## 1. 後端實作標準 (Backend) ---
所有 Model 之操作紀錄應遵循「僅儲存變動資料」與「保留關鍵快照」兩大原則。 ## 1. 後端實作核心 (Backend)
### 1.1 啟用 Activity Log ### 1.1 全域 ID 轉名稱邏輯 (Global ID Resolution)
為了讓管理者能直覺看懂日誌,所有的 ID`warehouse_id`, `created_by`)在記錄時都應自動解析為名稱。此邏輯應統一在 Model 的 `tapActivity` 中實作。
在 Model 中引用 `LogsActivity` trait 並實作 `getActivitylogOptions` 方法。
```php
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Product extends Model
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty() // ✅ 關鍵:只記錄有變動的欄位
->dontSubmitEmptyLogs(); // 若無變動則不記錄
}
}
```
### 1.2 手動記錄 (Manual Logging)
若需在 Controller 手動記錄(例如需客製化邏輯),**必須**自行實作變動過濾,不可直接儲存所有屬性。
**錯誤範例 (Do NOT do this):**
```php
// ❌ 錯誤:這會導致每次更新都記錄所有欄位,即使它們沒變
activity()
->withProperties(['attributes' => $newAttributes, 'old' => $oldAttributes])
->log('updated');
```
**正確範例 (Do this):**
```php
// ✅ 正確:自行比對差異,只存變動值
$changedAttributes = [];
$changedOldAttributes = [];
foreach ($newAttributes as $key => $value) {
if ($value != ($oldAttributes[$key] ?? null)) {
$changedAttributes[$key] = $value;
$changedOldAttributes[$key] = $oldAttributes[$key] ?? null;
}
}
if (!empty($changedAttributes)) {
activity()
->withProperties(['attributes' => $changedAttributes, 'old' => $changedOldAttributes])
->log('updated');
}
```
### 1.3 快照策略 (Snapshot Strategy)
為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊(如名稱、代號、類別名稱)。
**主要方式:使用 `tapActivity` (推薦)**
#### 關鍵實作參考:
```php ```php
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{ {
$properties = $activity->properties; // 🚩 核心:轉換為陣列以避免 Indirect modification error
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
// 1. Snapshot 快照:用於主描述的上下文(例如:單號、名稱)
$snapshot = $properties['snapshot'] ?? []; $snapshot = $properties['snapshot'] ?? [];
$snapshot['doc_no'] = $this->doc_no;
// 保存關鍵關聯名稱 (避免關聯資料刪除後 ID 失效) $snapshot['warehouse_name'] = $this->warehouse?->name;
$snapshot['category_name'] = $this->category ? $this->category->name : null;
$snapshot['po_number'] = $this->code; // 儲存單號
// 保存自身名稱 (Context)
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot; $properties['snapshot'] = $snapshot;
// 2. 名稱解析:自動將 attributes 與 old 中的 ID 換成人名/物名
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
// 使用者 ID 轉換
foreach (['created_by', 'updated_by', 'completed_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
// 倉庫 ID 轉換
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$data['warehouse_id'] = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id'])?->name;
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties; $activity->properties = $properties;
} }
``` ```
## 2. 顯示名稱映射 (UI Mapping) ### 1.2 複雜操作的日誌合併 (Log Consolidation)
當一個操作同時涉及「多個品項異動」與「單據狀態變更」時,**嚴禁**產生多筆重複日誌。
### 2.1 對象名稱映射 (Mapping) * **策略**:在 Service 層手動發送主日誌,並使用 `saveQuietly()` 更新單據屬性以抑止 Trait 的自動日誌。
* **格式**:主日誌應包含 `items_diff` (品項差異) 與 `attributes/old` (單據狀態變更)。
需在 `ActivityLogController.php` 中設定 Model 與中文名稱的對應,讓前端列表能顯示中文對象(如「公共事業費」而非 `UtilityFee`)。
**位置**: `app/Http/Controllers/Admin/ActivityLogController.php`
```php ```php
protected function getSubjectMap() // Service 中的實作方式
{ DB::transaction(function () use ($doc, $items) {
return [ // 1. 更新品項 (記錄變動細節)
'App\Modules\Inventory\Models\Product' => '商品', $updatedItems = $this->getUpdatedItems($doc, $items);
'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
]; // 2. 靜默更新單據狀態 (避免 Trait 產生冗餘日誌)
} $doc->status = 'completed';
$doc->saveQuietly();
// 3. 手動觸發單一合併日誌
activity()
->performedOn($doc)
->withProperties([
'items_diff' => ['updated' => $updatedItems],
'attributes' => ['status' => 'completed'],
'old' => ['status' => 'counting']
])
->log('updated');
});
``` ```
### 2.2 欄位名稱中文化 (Field Translation) ---
需在前端 `ActivityDetailDialog` 中設定欄位名稱的中文翻譯。 ## 2. 前端介面規範 (Frontend)
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx` ### 2.1 標籤命名規範 (Field Labels)
前端顯示應完全移除「ID」字眼提供最友善的閱讀體驗。
**檔案位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
```typescript ```typescript
const fieldLabels: Record<string, string> = { const fieldLabels: Record<string, string> = {
// ... 既有欄位 warehouse_id: '倉庫', // ❌ 禁用「倉庫 ID」
'transaction_date': '費用日期', created_by: '建立者', // ❌ 禁用「建立者 ID」
'category': '費用類別', completed_by: '完成者',
'amount': '金額', status: '狀態',
}; };
``` ```
## 3. 前端顯示邏輯 (Frontend) ### 2.2 特殊結構顯示
* **品項異動**:前端應能渲染 `items_diff` 結構,以「品項名稱 + 數值變動」的方式呈現表格(已在 `ActivityDetailDialog` 實作)。
### 3.1 列表描述生成 (Description Generation) ---
前端 `LogTable.tsx` 會依據 `properties.snapshot` 中的欄位自動組建描述例如「Admin 新增 電話費 公共事業費」)。 ## 3. 開發檢核清單 (Checklist)
若您的 Model 使用了特殊的識別欄位(例如 `category`**必須**將其加入 `nameParams` 陣列中。 - [ ] **Model**: `tapActivity` 是否已處理 Collection 快照?
- [ ] **Model**: 是否已實作全域 ID 至名稱的自動解析?
**位置**: `resources/js/Components/ActivityLog/LogTable.tsx` - [ ] **Service**: 是否使用 `saveQuietly()` 避免產生重複的「單據已更新」日誌?
- [ ] **UI**: `fieldLabels` 是否已移除所有「ID」字樣
```typescript - [ ] **UI**: 若有品項異動,是否已正確格式化傳入 `items_diff`
const nameParams = [
'po_number', 'name', 'code',
'category_name',
'category' // ✅ 確保加入此欄位,前端才能抓到 $snapshot['category']
];
```
### 3.2 詳情過濾邏輯
前端 `ActivityDetailDialog` 已內建智慧過濾邏輯:
- **Created**: 顯示初始化欄位。
- **Updated**: **僅顯示有變動的欄位** (由 `isChanged` 判斷)。
- **Deleted**: 顯示刪除前的完整資料。
開發者僅需確保傳入的 `attributes``old` 資料結構正確,過濾邏輯會自動運作。
## 檢核清單
- [ ] **Backend**: Model 是否已設定 `logOnlyDirty` 或手動實作過濾?
- [ ] **Backend**: 是否已透過 `tapActivity` 或手動方式記錄 Snapshot關鍵名稱
- [ ] **Backend**: 是否已在 `ActivityLogController` 加入 Model 中文名稱映射?
- [ ] **Frontend**: 是否已在 `ActivityDetailDialog` 加入欄位中文翻譯?
- [ ] **Frontend**: 若使用特殊識別欄位,是否已加入 `LogTable``nameParams`

View File

@@ -796,7 +796,42 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
```tsx ```tsx
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
import { Input } from "@/Components/ui/input";
## 11.7 金額與數字輸入規範
所有涉及金額(單價、成本、總價)的輸入框,應遵循以下規範以確保操作體驗一致:
1. **HTML 屬性**
* `type="number"`
* `min="0"` (除非業務邏輯允許負數)
* `step="any"` (設置為 `any` 可允許任意小數,且瀏覽器預設按上下鍵時會增減 **1** 並保留小數部分,例如 37.2 -> 38.2)
* **步進值 (Step)**: 金額與數量輸入框均應設定 `step="any"`,以支援小數點輸入(除非業務邏輯強制整數)。
* `placeholder="0"`
2. **樣式類別**
* 預設靠左對齊 (不需要 `text-right`),亦可依版面需求調整。
### 9.2 對齊方式 (Alignment)
依據欄位所在的情境區分對齊方式:
- **明細列表/表格 (Details/Table)**:金額與數量欄位一律 **靠右對齊 (text-right)**
- 包含:採購單明細、庫存盤點表、調撥單明細等 Table 內的輸入框。
- **一般表單/新增欄位 (Form/Input)**:金額與數量欄位一律 **靠左對齊 (text-left)**
- 包含:商品資料設定、新增表單中的獨立欄位。亦可依版面需求調整。
3. **行為邏輯**
* 輸入時允許輸入小數點。
* 鍵盤上下鍵調整時,瀏覽器會預設增減 1 (搭配 `step="any"`)。
```tsx
<Input
type="number"
min="0"
step="any"
value={price}
onChange={(e) => setPrice(parseFloat(e.target.value) || 0)}
placeholder="0"
/>
```
<div className="relative"> <div className="relative">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" /> <Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />

View File

@@ -28,6 +28,9 @@ class ActivityLogController extends Controller
'App\Modules\Production\Models\Recipe' => '生產配方', 'App\Modules\Production\Models\Recipe' => '生產配方',
'App\Modules\Production\Models\RecipeItem' => '配方品項', 'App\Modules\Production\Models\RecipeItem' => '配方品項',
'App\Modules\Production\Models\ProductionOrderItem' => '工單品項', 'App\Modules\Production\Models\ProductionOrderItem' => '工單品項',
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
]; ];
} }
@@ -81,6 +84,7 @@ class ActivityLogController extends Controller
} }
$activities = $query->paginate($perPage) $activities = $query->paginate($perPage)
->withQueryString()
->through(function ($activity) { ->through(function ($activity) {
$subjectMap = $this->getSubjectMap(); $subjectMap = $this->getSubjectMap();

View File

@@ -187,13 +187,13 @@ class RoleController extends Controller
'vendors' => '廠商資料管理', 'vendors' => '廠商資料管理',
'purchase_orders' => '採購單管理', 'purchase_orders' => '採購單管理',
'goods_receipts' => '進貨單管理', 'goods_receipts' => '進貨單管理',
'production_orders' => '生產工單管理',
'recipes' => '配方管理', 'recipes' => '配方管理',
'production_orders' => '生產工單管理',
'utility_fees' => '公共事業費管理',
'accounting' => '會計報表',
'users' => '使用者管理', 'users' => '使用者管理',
'roles' => '角色與權限', 'roles' => '角色與權限',
'system' => '系統管理', 'system' => '系統管理',
'utility_fees' => '公共事業費管理',
'accounting' => '會計報表',
]; ];
$result = []; $result = [];

View File

@@ -106,6 +106,16 @@ interface InventoryServiceInterface
*/ */
public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null); public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null);
/**
* Find a specific inventory record by warehouse, product and batch.
*
* @param int $warehouseId
* @param int $productId
* @param string|null $batchNumber
* @return object|null
*/
public function findInventoryByBatch(int $warehouseId, int $productId, ?string $batchNumber);
/** /**
* Get statistics for the dashboard. * Get statistics for the dashboard.
* *

View File

@@ -69,6 +69,12 @@ class AdjustDocController extends Controller
// 模式 1: 從盤點單建立 // 模式 1: 從盤點單建立
if ($request->filled('count_doc_id')) { if ($request->filled('count_doc_id')) {
$countDoc = InventoryCountDoc::findOrFail($request->count_doc_id); $countDoc = InventoryCountDoc::findOrFail($request->count_doc_id);
if ($countDoc->status !== 'completed') {
$errorMsg = $countDoc->status === 'no_adjust'
? '此盤點單無庫存差異,無需建立盤調單'
: '只有已完成盤點的單據可以建立盤調單';
return redirect()->back()->with('error', $errorMsg);
}
// 檢查是否已存在對應的盤調單 (避免重複建立) // 檢查是否已存在對應的盤調單 (避免重複建立)
if (InventoryAdjustDoc::where('count_doc_id', $countDoc->id)->exists()) { if (InventoryAdjustDoc::where('count_doc_id', $countDoc->id)->exists()) {
@@ -76,6 +82,7 @@ class AdjustDocController extends Controller
} }
$doc = $this->adjustService->createFromCountDoc($countDoc, auth()->id()); $doc = $this->adjustService->createFromCountDoc($countDoc, auth()->id());
return redirect()->route('inventory.adjust.show', [$doc->id]) return redirect()->route('inventory.adjust.show', [$doc->id])
->with('success', '已從盤點單生成盤調單'); ->with('success', '已從盤點單生成盤調單');
} }
@@ -127,10 +134,63 @@ class AdjustDocController extends Controller
return response()->json($counts); return response()->json($counts);
} }
public function update(Request $request, InventoryAdjustDoc $doc)
{
$action = $request->input('action', 'update');
if ($action === 'post') {
if ($doc->status !== 'draft') {
return redirect()->back()->with('error', '只有草稿狀態的單據可以過帳');
}
$this->adjustService->post($doc, auth()->id());
return redirect()->back()->with('success', '單據已過帳');
}
if ($action === 'void') {
if ($doc->status !== 'draft') {
return redirect()->back()->with('error', '只有草稿狀態的單據可以作廢');
}
$this->adjustService->void($doc, auth()->id());
return redirect()->back()->with('success', '單據已作廢');
}
// 一般更新 (更新品項與基本資訊)
if ($doc->status !== 'draft') {
return redirect()->back()->with('error', '只有草稿狀態的單據可以修改');
}
$request->validate([
'reason' => 'required|string',
'remarks' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required',
'items.*.adjust_qty' => 'required|numeric',
]);
$doc->update([
'reason' => $request->reason,
'remarks' => $request->remarks,
]);
$this->adjustService->updateItems($doc, $request->items);
return redirect()->back()->with('success', '單據已更新');
}
public function show(InventoryAdjustDoc $doc) public function show(InventoryAdjustDoc $doc)
{ {
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']); $doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']);
// Pre-fetch relevant Inventory information (mainly for expiry date)
$inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed()
->where('warehouse_id', $doc->warehouse_id)
->whereIn('product_id', $doc->items->pluck('product_id'))
->whereIn('batch_number', $doc->items->pluck('batch_number'))
->get()
->mapWithKeys(function ($inv) {
return [$inv->product_id . '-' . $inv->batch_number => $inv];
});
$docData = [ $docData = [
'id' => (string) $doc->id, 'id' => (string) $doc->id,
'doc_no' => $doc->doc_no, 'doc_no' => $doc->doc_no,
@@ -143,13 +203,15 @@ class AdjustDocController extends Controller
'created_by' => $doc->createdBy?->name, 'created_by' => $doc->createdBy?->name,
'count_doc_id' => $doc->count_doc_id ? (string)$doc->count_doc_id : null, 'count_doc_id' => $doc->count_doc_id ? (string)$doc->count_doc_id : null,
'count_doc_no' => $doc->countDoc?->doc_no, 'count_doc_no' => $doc->countDoc?->doc_no,
'items' => $doc->items->map(function ($item) { 'items' => $doc->items->map(function ($item) use ($inventoryMap) {
$inv = $inventoryMap->get($item->product_id . '-' . $item->batch_number);
return [ return [
'id' => (string) $item->id, 'id' => (string) $item->id,
'product_id' => (string) $item->product_id, 'product_id' => (string) $item->product_id,
'product_name' => $item->product->name, 'product_name' => $item->product->name,
'product_code' => $item->product->code, 'product_code' => $item->product->code,
'batch_number' => $item->batch_number, 'batch_number' => $item->batch_number,
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
'unit' => $item->product->baseUnit?->name, 'unit' => $item->product->baseUnit?->name,
'qty_before' => (float) $item->qty_before, 'qty_before' => (float) $item->qty_before,
'adjust_qty' => (float) $item->adjust_qty, 'adjust_qty' => (float) $item->adjust_qty,
@@ -163,44 +225,14 @@ class AdjustDocController extends Controller
]); ]);
} }
public function update(Request $request, InventoryAdjustDoc $doc)
{
if ($doc->status !== 'draft') {
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
}
// 提交 (items 更新 或 過帳)
if ($request->input('action') === 'post') {
$this->adjustService->post($doc, auth()->id());
return redirect()->route('inventory.adjust.index')
->with('success', '盤調單已過帳生效');
}
// 僅儲存資料
$validated = $request->validate([
'items' => 'array',
'items.*.product_id' => 'required|exists:products,id',
'items.*.adjust_qty' => 'required|numeric', // 可以是負數
'items.*.batch_number' => 'nullable|string',
'items.*.notes' => 'nullable|string',
]);
if ($request->has('items')) {
$this->adjustService->updateItems($doc, $validated['items']);
}
// 更新表頭
$doc->update($request->only(['reason', 'remarks']));
return redirect()->back()->with('success', '儲存成功');
}
public function destroy(InventoryAdjustDoc $doc) public function destroy(InventoryAdjustDoc $doc)
{ {
if ($doc->status !== 'draft') { if ($doc->status !== 'draft') {
return redirect()->back()->with('error', '只能刪除草稿狀態的單據'); return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
} }
$doc->items()->delete(); $doc->items()->delete();
$doc->delete(); $doc->delete();

View File

@@ -84,7 +84,7 @@ class CountDocController extends Controller
); );
// 自動執行快照 // 自動執行快照
$this->countService->snapshot($doc); $this->countService->snapshot($doc, false);
return redirect()->route('inventory.count.show', [$doc->id]) return redirect()->route('inventory.count.show', [$doc->id])
->with('success', '已建立盤點單並完成庫存快照'); ->with('success', '已建立盤點單並完成庫存快照');
@@ -94,6 +94,16 @@ class CountDocController extends Controller
{ {
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']); $doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
// 預先抓取相關的 Inventory 資訊 (主要為了取得效期)
$inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed()
->where('warehouse_id', $doc->warehouse_id)
->whereIn('product_id', $doc->items->pluck('product_id'))
->whereIn('batch_number', $doc->items->pluck('batch_number'))
->get()
->mapWithKeys(function ($inv) {
return [$inv->product_id . '-' . $inv->batch_number => $inv];
});
$docData = [ $docData = [
'id' => (string) $doc->id, 'id' => (string) $doc->id,
'doc_no' => $doc->doc_no, 'doc_no' => $doc->doc_no,
@@ -103,12 +113,16 @@ class CountDocController extends Controller
'remarks' => $doc->remarks, 'remarks' => $doc->remarks,
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null, 'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null,
'created_by' => $doc->createdBy?->name, 'created_by' => $doc->createdBy?->name,
'items' => $doc->items->map(function ($item) { 'items' => $doc->items->map(function ($item) use ($inventoryMap) {
$key = $item->product_id . '-' . $item->batch_number;
$inv = $inventoryMap->get($key);
return [ return [
'id' => (string) $item->id, 'id' => (string) $item->id,
'product_name' => $item->product->name, 'product_name' => $item->product->name,
'product_code' => $item->product->code, 'product_code' => $item->product->code,
'batch_number' => $item->batch_number, 'batch_number' => $item->batch_number,
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, // 新增效期
'unit' => $item->product->baseUnit?->name, 'unit' => $item->product->baseUnit?->name,
'system_qty' => (float) $item->system_qty, 'system_qty' => (float) $item->system_qty,
'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty, 'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty,
@@ -173,31 +187,37 @@ class CountDocController extends Controller
$this->countService->updateCount($doc, $validated['items']); $this->countService->updateCount($doc, $validated['items']);
} }
// 如果是按了 "完成盤點" // 重新讀取以獲取最新狀態
if ($request->input('action') === 'complete') { $doc->refresh();
$this->countService->complete($doc, auth()->id());
if ($doc->status === 'completed') {
return redirect()->route('inventory.count.index') return redirect()->route('inventory.count.index')
->with('success', '盤點單已完成'); ->with('success', '盤點完成,單據已自動存檔並完成');
} }
return redirect()->back()->with('success', '暫存成功'); return redirect()->back()->with('success', '盤點資料已暫存');
} }
public function reopen(InventoryCountDoc $doc) public function reopen(InventoryCountDoc $doc)
{ {
if ($doc->status !== 'completed') { // 權限檢查 (通常僅允許有權限者執行,例如 inventory.adjust)
return redirect()->back()->with('error', '只有已核准的盤點單可以取消核准'); // 注意:前端已經用 <Can> 保護按鈕,後端這裡最好也加上檢查
if (!auth()->user()->can('inventory.adjust')) {
abort(403);
} }
// TODO: Move logic to Service if complex if (!in_array($doc->status, ['completed', 'no_adjust'])) {
return redirect()->back()->with('error', '僅能針對已完成或無需盤調的盤點單重新開啟盤點');
}
// 執行取消核准邏輯
$doc->update([ $doc->update([
'status' => 'counting', // Revert to counting (draft) 'status' => 'counting', // 回復為盤點中
'completed_at' => null, 'completed_at' => null, // 清除完成時間
'completed_by' => null, 'completed_by' => null, // 清除完成者
]); ]);
return redirect()->route('inventory.count.show', [$doc->id]) return redirect()->back()->with('success', '已重新開啟盤點,單據回復為盤點中狀態');
->with('success', '已取消核准,單據回復為盤點中狀態');
} }
public function destroy(InventoryCountDoc $doc) public function destroy(InventoryCountDoc $doc)
@@ -206,6 +226,8 @@ class CountDocController extends Controller
return redirect()->back()->with('error', '已完成的盤點單無法刪除'); return redirect()->back()->with('error', '已完成的盤點單無法刪除');
} }
// Activity Log handled by Model Trait
$doc->items()->delete(); $doc->items()->delete();
$doc->delete(); $doc->delete();

View File

@@ -131,16 +131,18 @@ class InventoryController extends Controller
{ {
// ... (unchanged) ... // ... (unchanged) ...
$products = Product::with(['baseUnit', 'largeUnit']) $products = Product::with(['baseUnit', 'largeUnit'])
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate') ->select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
->get() ->get()
->map(function ($product) { ->map(function ($product) {
return [ return [
'id' => (string) $product->id, 'id' => (string) $product->id,
'name' => $product->name, 'name' => $product->name,
'code' => $product->code, 'code' => $product->code,
'barcode' => $product->barcode,
'baseUnit' => $product->baseUnit?->name ?? '個', 'baseUnit' => $product->baseUnit?->name ?? '個',
'largeUnit' => $product->largeUnit?->name, // 可能為 null 'largeUnit' => $product->largeUnit?->name, // 可能為 null
'conversionRate' => (float) $product->conversion_rate, 'conversionRate' => (float) $product->conversion_rate,
'costPrice' => (float) $product->cost_price,
]; ];
}); });

View File

@@ -96,6 +96,10 @@ class ProductController extends Controller
] : null, ] : null,
'conversionRate' => (float) $product->conversion_rate, 'conversionRate' => (float) $product->conversion_rate,
'location' => $product->location, 'location' => $product->location,
'cost_price' => (float) $product->cost_price,
'price' => (float) $product->price,
'member_price' => (float) $product->member_price,
'wholesale_price' => (float) $product->wholesale_price,
]; ];
}); });
@@ -126,7 +130,12 @@ class ProductController extends Controller
'large_unit_id' => 'nullable|exists:units,id', 'large_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001', 'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
'purchase_unit_id' => 'nullable|exists:units,id', 'purchase_unit_id' => 'nullable|exists:units,id',
'purchase_unit_id' => 'nullable|exists:units,id',
'location' => 'nullable|string|max:255', 'location' => 'nullable|string|max:255',
'cost_price' => 'nullable|numeric|min:0',
'price' => 'nullable|numeric|min:0',
'member_price' => 'nullable|numeric|min:0',
'wholesale_price' => 'nullable|numeric|min:0',
], [ ], [
'code.required' => '商品代號為必填', 'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 8 碼', 'code.max' => '商品代號最多 8 碼',
@@ -142,6 +151,14 @@ class ProductController extends Controller
'conversion_rate.required_with' => '填寫大單位時,換算率為必填', 'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
'conversion_rate.numeric' => '換算率必須為數字', 'conversion_rate.numeric' => '換算率必須為數字',
'conversion_rate.min' => '換算率最小為 0.0001', 'conversion_rate.min' => '換算率最小為 0.0001',
'cost_price.numeric' => '成本價必須為數字',
'cost_price.min' => '成本價不能小於 0',
'price.numeric' => '售價必須為數字',
'price.min' => '售價不能小於 0',
'member_price.numeric' => '會員價必須為數字',
'member_price.min' => '會員價不能小於 0',
'wholesale_price.numeric' => '批發價必須為數字',
'wholesale_price.min' => '批發價不能小於 0',
]); ]);
$product = Product::create($validated); $product = Product::create($validated);
@@ -165,7 +182,12 @@ class ProductController extends Controller
'large_unit_id' => 'nullable|exists:units,id', 'large_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001', 'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
'purchase_unit_id' => 'nullable|exists:units,id', 'purchase_unit_id' => 'nullable|exists:units,id',
'purchase_unit_id' => 'nullable|exists:units,id',
'location' => 'nullable|string|max:255', 'location' => 'nullable|string|max:255',
'cost_price' => 'nullable|numeric|min:0',
'price' => 'nullable|numeric|min:0',
'member_price' => 'nullable|numeric|min:0',
'wholesale_price' => 'nullable|numeric|min:0',
], [ ], [
'code.required' => '商品代號為必填', 'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 8 碼', 'code.max' => '商品代號最多 8 碼',
@@ -181,6 +203,14 @@ class ProductController extends Controller
'conversion_rate.required_with' => '填寫大單位時,換算率為必填', 'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
'conversion_rate.numeric' => '換算率必須為數字', 'conversion_rate.numeric' => '換算率必須為數字',
'conversion_rate.min' => '換算率最小為 0.0001', 'conversion_rate.min' => '換算率最小為 0.0001',
'cost_price.numeric' => '成本價必須為數字',
'cost_price.min' => '成本價不能小於 0',
'price.numeric' => '售價必須為數字',
'price.min' => '售價不能小於 0',
'member_price.numeric' => '會員價必須為數字',
'member_price.min' => '會員價不能小於 0',
'wholesale_price.numeric' => '批發價必須為數字',
'wholesale_price.min' => '批發價不能小於 0',
]); ]);
$product->update($validated); $product->update($validated);

View File

@@ -82,19 +82,10 @@ class TransferOrderController extends Controller
auth()->id() auth()->id()
); );
// 如果請求包含單筆商品資訊
if ($request->has('product_id')) {
$this->transferService->updateItems($order, [[
'product_id' => $validated['product_id'],
'quantity' => $validated['quantity'],
'batch_number' => $validated['batch_number'] ?? null,
]]);
}
// 如果是撥補單,執行直接過帳
if ($request->input('instant_post') === true) { if ($request->input('instant_post') === true) {
try { try {
$this->transferService->post($order, auth()->id()); $this->transferService->post($order, auth()->id());
return redirect()->back()->with('success', '撥補成功,庫存已更新'); return redirect()->back()->with('success', '撥補成功,庫存已更新');
} catch (\Exception $e) { } catch (\Exception $e) {
// 如果過帳失敗,雖然單據已建立,但應回報錯誤 // 如果過帳失敗,雖然單據已建立,但應回報錯誤
@@ -134,9 +125,10 @@ class TransferOrderController extends Controller
'product_name' => $item->product->name, 'product_name' => $item->product->name,
'product_code' => $item->product->code, 'product_code' => $item->product->code,
'batch_number' => $item->batch_number, 'batch_number' => $item->batch_number,
'expiry_date' => $stock && $stock->expiry_date ? $stock->expiry_date->format('Y-m-d') : null,
'unit' => $item->product->baseUnit?->name, 'unit' => $item->product->baseUnit?->name,
'quantity' => (float) $item->quantity, 'quantity' => (float) $item->quantity,
'max_quantity' => $stock ? (float) $stock->quantity : 0.0, 'max_quantity' => $item->snapshot_quantity ? (float) $item->snapshot_quantity : ($stock ? (float) $stock->quantity : 0.0),
'notes' => $item->notes, 'notes' => $item->notes,
]; ];
}), }),
@@ -153,6 +145,34 @@ class TransferOrderController extends Controller
return redirect()->back()->with('error', '只能修改草稿狀態的單據'); return redirect()->back()->with('error', '只能修改草稿狀態的單據');
} }
$validated = $request->validate([
'items' => 'array',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.batch_number' => 'nullable|string',
'items.*.notes' => 'nullable|string',
'remarks' => 'nullable|string',
]);
// 1. 先更新資料
$itemsChanged = false;
if ($request->has('items')) {
$itemsChanged = $this->transferService->updateItems($order, $validated['items']);
}
$remarksChanged = $order->remarks !== ($validated['remarks'] ?? null);
if ($itemsChanged || $remarksChanged) {
$order->remarks = $validated['remarks'] ?? null;
// [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌
$order->touch();
$message = '儲存成功';
} else {
$message = '資料未變更';
// 如果沒變更,就不執行 touch(),也不會產生 Activity Log
}
// 2. 判斷是否需要過帳
if ($request->input('action') === 'post') { if ($request->input('action') === 'post') {
try { try {
$this->transferService->post($order, auth()->id()); $this->transferService->post($order, auth()->id());
@@ -163,21 +183,7 @@ class TransferOrderController extends Controller
} }
} }
$validated = $request->validate([ return redirect()->back()->with('success', $message);
'items' => 'array',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.batch_number' => 'nullable|string',
'items.*.notes' => 'nullable|string',
]);
if ($request->has('items')) {
$this->transferService->updateItems($order, $validated['items']);
}
$order->update($request->only(['remarks']));
return redirect()->back()->with('success', '儲存成功');
} }
public function destroy(InventoryTransferOrder $order) public function destroy(InventoryTransferOrder $order)
@@ -185,6 +191,7 @@ class TransferOrderController extends Controller
if ($order->status !== 'draft') { if ($order->status !== 'draft') {
return redirect()->back()->with('error', '只能刪除草稿狀態的單據'); return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
} }
$order->items()->delete(); $order->items()->delete();
$order->delete(); $order->delete();

View File

@@ -5,7 +5,7 @@ namespace App\Modules\Inventory\Exports;
use Maatwebsite\Excel\Concerns\WithHeadings; use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithColumnFormatting; use Maatwebsite\Excel\Concerns\WithColumnFormatting;
use Maatwebsite\Excel\Concerns\WithHeadings; // use Maatwebsite\Excel\Concerns\WithHeadings;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
class ProductTemplateExport implements WithHeadings, WithColumnFormatting class ProductTemplateExport implements WithHeadings, WithColumnFormatting
@@ -22,6 +22,10 @@ class ProductTemplateExport implements WithHeadings, WithColumnFormatting
'基本單位', '基本單位',
'大單位', '大單位',
'換算率', '換算率',
'成本價',
'售價',
'會員價',
'批發價',
]; ];
} }

View File

@@ -74,6 +74,10 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
'large_unit_id' => $largeUnitId, 'large_unit_id' => $largeUnitId,
'conversion_rate' => $row['換算率'] ?? null, 'conversion_rate' => $row['換算率'] ?? null,
'purchase_unit_id' => null, 'purchase_unit_id' => null,
'cost_price' => $row['成本價'] ?? null,
'price' => $row['售價'] ?? null,
'member_price' => $row['會員價'] ?? null,
'wholesale_price' => $row['批發價'] ?? null,
]); ]);
} }
@@ -100,6 +104,10 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
}], }],
'換算率' => ['nullable', 'numeric', 'min:0.0001', 'required_with:大單位'], '換算率' => ['nullable', 'numeric', 'min:0.0001', 'required_with:大單位'],
'成本價' => ['nullable', 'numeric', 'min:0'],
'售價' => ['nullable', 'numeric', 'min:0'],
'會員價' => ['nullable', 'numeric', 'min:0'],
'批發價' => ['nullable', 'numeric', 'min:0'],
]; ];
} }
} }

View File

@@ -8,9 +8,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Modules\Core\Models\User; use App\Modules\Core\Models\User;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class InventoryAdjustDoc extends Model class InventoryAdjustDoc extends Model
{ {
use HasFactory; use HasFactory, LogsActivity;
protected $fillable = [ protected $fillable = [
'doc_no', 'doc_no',
@@ -36,7 +39,7 @@ class InventoryAdjustDoc extends Model
static::creating(function ($model) { static::creating(function ($model) {
if (empty($model->doc_no)) { if (empty($model->doc_no)) {
$today = date('Ymd'); $today = date('Ymd');
$prefix = 'ADJ' . $today; $prefix = 'ADJ-' . $today . '-';
$lastDoc = static::where('doc_no', 'like', $prefix . '%') $lastDoc = static::where('doc_no', 'like', $prefix . '%')
->orderBy('doc_no', 'desc') ->orderBy('doc_no', 'desc')
@@ -78,4 +81,64 @@ class InventoryAdjustDoc extends Model
{ {
return $this->belongsTo(User::class, 'posted_by'); return $this->belongsTo(User::class, 'posted_by');
} }
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
// 確保為陣列以進行修改
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
// Snapshot key information
$snapshot['doc_no'] = $this->doc_no;
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : null;
$snapshot['posted_at'] = $this->posted_at ? $this->posted_at->format('Y-m-d H:i:s') : null;
$snapshot['status'] = $this->status;
$snapshot['created_by_name'] = $this->createdBy ? $this->createdBy->name : null;
$snapshot['posted_by_name'] = $this->postedBy ? $this->postedBy->name : null;
$properties['snapshot'] = $snapshot;
// 全域 ID 轉名稱邏輯 (用於 attributes 與 old)
$convertIdsToNames = function (&$data) {
if (empty($data) || !is_array($data)) return;
// 倉庫 ID 轉換
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$warehouse = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id']);
if ($warehouse) {
$data['warehouse_id'] = $warehouse->name;
}
}
// 使用者 ID 轉換
$userFields = ['created_by', 'updated_by', 'posted_by'];
foreach ($userFields as $field) {
if (isset($data[$field]) && is_numeric($data[$field])) {
$user = \App\Modules\Core\Models\User::find($data[$field]);
if ($user) {
$data[$field] = $user->name;
}
}
}
};
if (isset($properties['attributes'])) {
$convertIdsToNames($properties['attributes']);
}
if (isset($properties['old'])) {
$convertIdsToNames($properties['old']);
}
$activity->properties = $properties;
}
} }

View File

@@ -7,10 +7,13 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Modules\Core\Models\User; use App\Modules\Core\Models\User;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class InventoryCountDoc extends Model class InventoryCountDoc extends Model
{ {
use HasFactory; use HasFactory;
use LogsActivity;
protected $fillable = [ protected $fillable = [
'doc_no', 'doc_no',
@@ -36,7 +39,7 @@ class InventoryCountDoc extends Model
static::creating(function ($model) { static::creating(function ($model) {
if (empty($model->doc_no)) { if (empty($model->doc_no)) {
$today = date('Ymd'); $today = date('Ymd');
$prefix = 'CNT' . $today; $prefix = 'CNT-' . $today . '-';
// 查詢當天編號最大的單據 // 查詢當天編號最大的單據
$lastDoc = static::where('doc_no', 'like', $prefix . '%') $lastDoc = static::where('doc_no', 'like', $prefix . '%')
@@ -75,4 +78,64 @@ class InventoryCountDoc extends Model
{ {
return $this->belongsTo(User::class, 'completed_by'); return $this->belongsTo(User::class, 'completed_by');
} }
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
{
return \Spatie\Activitylog\LogOptions::defaults()
->logFillable()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
// 確保為陣列以進行修改
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
// Snapshot key information
$snapshot['doc_no'] = $this->doc_no;
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : null;
$snapshot['completed_at'] = $this->completed_at ? $this->completed_at->format('Y-m-d H:i:s') : null;
$snapshot['status'] = $this->status;
$snapshot['created_by_name'] = $this->createdBy ? $this->createdBy->name : null;
$snapshot['completed_by_name'] = $this->completedBy ? $this->completedBy->name : null;
$properties['snapshot'] = $snapshot;
// 全域 ID 轉名稱邏輯 (用於 attributes 與 old)
$convertIdsToNames = function (&$data) {
if (empty($data) || !is_array($data)) return;
// 倉庫 ID 轉換
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$warehouse = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id']);
if ($warehouse) {
$data['warehouse_id'] = $warehouse->name;
}
}
// 使用者 ID 轉換
$userFields = ['created_by', 'updated_by', 'completed_by'];
foreach ($userFields as $field) {
if (isset($data[$field]) && is_numeric($data[$field])) {
$user = \App\Modules\Core\Models\User::find($data[$field]);
if ($user) {
$data[$field] = $user->name;
}
}
}
};
if (isset($properties['attributes'])) {
$convertIdsToNames($properties['attributes']);
}
if (isset($properties['old'])) {
$convertIdsToNames($properties['old']);
}
$activity->properties = $properties;
}
} }

View File

@@ -15,6 +15,7 @@ class InventoryTransferItem extends Model
'product_id', 'product_id',
'batch_number', 'batch_number',
'quantity', 'quantity',
'snapshot_quantity',
'notes', 'notes',
]; ];

View File

@@ -1,16 +1,106 @@
<?php <?php
namespace App\Modules\Inventory\Models; namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use App\Modules\Core\Models\User; use App\Modules\Core\Models\User;
class InventoryTransferOrder extends Model class InventoryTransferOrder extends Model
{ {
use HasFactory; use HasFactory, LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->dontSubmitEmptyLogs();
}
/**
* @var array 暫存的活動紀錄屬性 (不會存入資料庫)
*/
public $activityProperties = [];
/**
* 自定義日誌屬性名稱解析
*/
public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName)
{
$properties = $activity->properties->toArray();
// 處置日誌事件說明
if ($eventName === 'created') {
$activity->description = 'created';
} elseif ($eventName === 'updated') {
// 如果屬性中有 status 且變更為 completed將描述改為 posted
if (isset($properties['attributes']['status']) && $properties['attributes']['status'] === 'completed') {
$activity->description = 'posted';
$eventName = 'posted'; // 供後續快照邏輯判定
} else {
$activity->description = 'updated';
}
}
// 處理倉庫 ID 轉名稱
$idToNameFields = [
'from_warehouse_id' => 'fromWarehouse',
'to_warehouse_id' => 'toWarehouse',
'created_by' => 'createdBy',
'posted_by' => 'postedBy',
];
foreach (['attributes', 'old'] as $part) {
if (isset($properties[$part])) {
foreach ($idToNameFields as $idField => $relation) {
if (isset($properties[$part][$idField])) {
$id = $properties[$part][$idField];
$nameField = str_replace('_id', '_name', $idField);
$name = null;
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
$name = $this->$relation->name;
} else {
$model = $this->$relation()->getRelated()->find($id);
$name = $model ? $model->name : "ID: $id";
}
$properties[$part][$nameField] = $name;
}
}
}
}
// 基本單據資訊快照 (包含單號、來源、目的地)
if (in_array($eventName, ['created', 'updated', 'posted', 'deleted'])) {
$properties['snapshot'] = [
'doc_no' => $this->doc_no,
'from_warehouse_name' => $this->fromWarehouse?->name,
'to_warehouse_name' => $this->toWarehouse?->name,
'status' => $this->status,
];
}
// 移除輔助欄位與雜訊
if (isset($properties['attributes'])) {
unset($properties['attributes']['from_warehouse_name']);
unset($properties['attributes']['to_warehouse_name']);
unset($properties['attributes']['activityProperties']);
unset($properties['attributes']['updated_at']);
}
if (isset($properties['old'])) {
unset($properties['old']['updated_at']);
}
// 合併暫存屬性 (例如 items_diff)
if (!empty($this->activityProperties)) {
$properties = array_merge($properties, $this->activityProperties);
}
$activity->properties = collect($properties);
}
protected $fillable = [ protected $fillable = [
'doc_no', 'doc_no',
@@ -35,7 +125,7 @@ class InventoryTransferOrder extends Model
static::creating(function ($model) { static::creating(function ($model) {
if (empty($model->doc_no)) { if (empty($model->doc_no)) {
$today = date('Ymd'); $today = date('Ymd');
$prefix = 'TRF' . $today; $prefix = 'TRF-' . $today . '-';
$lastDoc = static::where('doc_no', 'like', $prefix . '%') $lastDoc = static::where('doc_no', 'like', $prefix . '%')
->orderBy('doc_no', 'desc') ->orderBy('doc_no', 'desc')

View File

@@ -27,6 +27,10 @@ class Product extends Model
'conversion_rate', 'conversion_rate',
'purchase_unit_id', 'purchase_unit_id',
'location', 'location',
'cost_price',
'price',
'member_price',
'wholesale_price',
]; ];
protected $casts = [ protected $casts = [

View File

@@ -60,6 +60,21 @@ class AdjustService
public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void
{ {
DB::transaction(function () use ($doc, $itemsData) { DB::transaction(function () use ($doc, $itemsData) {
$updatedItems = [];
$oldItems = $doc->items()->with('product')->get();
// 記錄舊品項狀態 (用於標註異動)
foreach ($oldItems as $oldItem) {
$updatedItems[] = [
'product_name' => $oldItem->product->name,
'old' => [
'adjust_qty' => (float)$oldItem->adjust_qty,
'notes' => $oldItem->notes,
],
'new' => null // 標記為刪除或待更新
];
}
$doc->items()->delete(); $doc->items()->delete();
foreach ($itemsData as $data) { foreach ($itemsData as $data) {
@@ -71,13 +86,60 @@ class AdjustService
$qtyBefore = $inventory ? $inventory->quantity : 0; $qtyBefore = $inventory ? $inventory->quantity : 0;
$doc->items()->create([ $newItem = $doc->items()->create([
'product_id' => $data['product_id'], 'product_id' => $data['product_id'],
'batch_number' => $data['batch_number'] ?? null, 'batch_number' => $data['batch_number'] ?? null,
'qty_before' => $qtyBefore, 'qty_before' => $qtyBefore,
'adjust_qty' => $data['adjust_qty'], 'adjust_qty' => $data['adjust_qty'],
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
// 更新日誌中的品項列表
$productName = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name;
$found = false;
foreach ($updatedItems as $idx => $ui) {
if ($ui['product_name'] === $productName && $ui['new'] === null) {
$updatedItems[$idx]['new'] = [
'adjust_qty' => (float)$data['adjust_qty'],
'notes' => $data['notes'] ?? null,
];
$found = true;
break;
}
}
if (!$found) {
$updatedItems[] = [
'product_name' => $productName,
'old' => null,
'new' => [
'adjust_qty' => (float)$data['adjust_qty'],
'notes' => $data['notes'] ?? null,
]
];
}
}
// 清理沒被更新到的舊品項 (即真正被刪除的)
$finalUpdatedItems = [];
foreach ($updatedItems as $ui) {
if ($ui['old'] === null && $ui['new'] === null) continue;
// 比對是否有實質變動
if ($ui['old'] != $ui['new']) {
$finalUpdatedItems[] = $ui;
}
}
if (!empty($finalUpdatedItems)) {
activity()
->performedOn($doc)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'items_diff' => [
'updated' => $finalUpdatedItems,
]
])
->log('updated');
} }
}); });
} }
@@ -88,13 +150,11 @@ class AdjustService
public function post(InventoryAdjustDoc $doc, int $userId): void public function post(InventoryAdjustDoc $doc, int $userId): void
{ {
DB::transaction(function () use ($doc, $userId) { DB::transaction(function () use ($doc, $userId) {
$oldStatus = $doc->status;
foreach ($doc->items as $item) { foreach ($doc->items as $item) {
if ($item->adjust_qty == 0) continue; if ($item->adjust_qty == 0) continue;
// 找尋或建立 Inventory
// 若是減少庫存,必須確保 Inventory 存在 (且理論上不能變負? 視策略而定,這裡假設允許變負或由 InventoryService 控管)
// 若是增加庫存,若不存在需建立
$inventory = Inventory::firstOrNew([ $inventory = Inventory::firstOrNew([
'warehouse_id' => $doc->warehouse_id, 'warehouse_id' => $doc->warehouse_id,
'product_id' => $item->product_id, 'product_id' => $item->product_id,
@@ -103,7 +163,6 @@ class AdjustService
// 如果是新建立的 object (id 為空),需要初始化 default // 如果是新建立的 object (id 為空),需要初始化 default
if (!$inventory->exists) { if (!$inventory->exists) {
// 繼承 Product 成本或預設 0 (簡化處理)
$inventory->unit_cost = $item->product->cost ?? 0; $inventory->unit_cost = $item->product->cost ?? 0;
$inventory->quantity = 0; $inventory->quantity = 0;
} }
@@ -112,7 +171,6 @@ class AdjustService
$newQty = $oldQty + $item->adjust_qty; $newQty = $oldQty + $item->adjust_qty;
$inventory->quantity = $newQty; $inventory->quantity = $newQty;
// 用最新的數量 * 單位成本 (簡化成本計算,不採用移動加權)
$inventory->total_value = $newQty * $inventory->unit_cost; $inventory->total_value = $newQty * $inventory->unit_cost;
$inventory->save(); $inventory->save();
@@ -129,17 +187,53 @@ class AdjustService
]); ]);
} }
$doc->update([ // 使用 saveQuietly 避免重複產生自動日誌
$doc->status = 'posted';
$doc->posted_at = now();
$doc->posted_by = $userId;
$doc->saveQuietly();
// 準備品項快照供日誌使用
$itemsSnapshot = $doc->items->map(function($item) {
return [
'product_name' => $item->product->name,
'old' => null, // 過帳視為整單生效,不顯示個別欄位差異
'new' => [
'adjust_qty' => (float)$item->adjust_qty,
'notes' => $item->notes,
]
];
})->toArray();
// 手動產生過帳日誌
activity()
->performedOn($doc)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => [
'status' => 'posted', 'status' => 'posted',
'posted_at' => now(), 'posted_at' => $doc->posted_at->format('Y-m-d H:i:s'),
'posted_by' => $userId, 'posted_by' => $userId,
]); ],
'old' => [
'status' => $oldStatus,
'posted_at' => null,
'posted_by' => null,
],
'items_diff' => [
'updated' => $itemsSnapshot,
]
])
->log('posted');
// 4. 若關聯盤點單,連動更新盤點單狀態 // 4. 若關聯盤點單,連動更新盤點單狀態
if ($doc->count_doc_id) { if ($doc->count_doc_id) {
InventoryCountDoc::where('id', $doc->count_doc_id)->update([ $countDoc = InventoryCountDoc::find($doc->count_doc_id);
'status' => 'adjusted' if ($countDoc) {
]); $countDoc->status = 'adjusted';
$countDoc->saveQuietly(); // 盤點單也靜默更新
}
} }
}); });
} }
@@ -152,9 +246,20 @@ class AdjustService
if ($doc->status !== 'draft') { if ($doc->status !== 'draft') {
throw new \Exception('只能作廢草稿狀態的單據'); throw new \Exception('只能作廢草稿狀態的單據');
} }
$doc->update([
'status' => 'voided', $oldStatus = $doc->status;
'updated_by' => $userId $doc->status = 'voided';
]); $doc->updated_by = $userId;
$doc->saveQuietly();
activity()
->performedOn($doc)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => ['status' => 'voided'],
'old' => ['status' => $oldStatus]
])
->log('voided');
} }
} }

View File

@@ -20,7 +20,8 @@ class CountService
return DB::transaction(function () use ($warehouseId, $remarks, $userId) { return DB::transaction(function () use ($warehouseId, $remarks, $userId) {
$doc = InventoryCountDoc::create([ $doc = InventoryCountDoc::create([
'warehouse_id' => $warehouseId, 'warehouse_id' => $warehouseId,
'status' => 'draft', 'status' => 'counting',
'snapshot_date' => now(),
'remarks' => $remarks, 'remarks' => $remarks,
'created_by' => $userId, 'created_by' => $userId,
]); ]);
@@ -32,9 +33,9 @@ class CountService
/** /**
* 執行快照:鎖定當前庫存量 * 執行快照:鎖定當前庫存量
*/ */
public function snapshot(InventoryCountDoc $doc): void public function snapshot(InventoryCountDoc $doc, bool $updateDoc = true): void
{ {
DB::transaction(function () use ($doc) { DB::transaction(function () use ($doc, $updateDoc) {
// 清除舊的 items (如果有) // 清除舊的 items (如果有)
$doc->items()->delete(); $doc->items()->delete();
@@ -62,10 +63,12 @@ class CountService
InventoryCountItem::insert($items); InventoryCountItem::insert($items);
} }
if ($updateDoc) {
$doc->update([ $doc->update([
'status' => 'counting', 'status' => 'counting',
'snapshot_date' => now(), 'snapshot_date' => now(),
]); ]);
}
}); });
} }
@@ -91,19 +94,115 @@ class CountService
public function updateCount(InventoryCountDoc $doc, array $itemsData): void public function updateCount(InventoryCountDoc $doc, array $itemsData): void
{ {
DB::transaction(function () use ($doc, $itemsData) { DB::transaction(function () use ($doc, $itemsData) {
$updatedItems = [];
$hasChanges = false;
$oldDocAttributes = [
'status' => $doc->status,
'completed_at' => $doc->completed_at ? $doc->completed_at->format('Y-m-d H:i:s') : null,
'completed_by' => $doc->completed_by,
];
foreach ($itemsData as $data) { foreach ($itemsData as $data) {
$item = $doc->items()->find($data['id']); $item = $doc->items()->with('product')->find($data['id']);
if ($item) { if ($item) {
$oldQty = $item->counted_qty;
$newQty = $data['counted_qty'];
$oldNotes = $item->notes;
$newNotes = $data['notes'] ?? $item->notes;
$isQtyChanged = $oldQty != $newQty;
$isNotesChanged = $oldNotes !== $newNotes;
if ($isQtyChanged || $isNotesChanged) {
$updatedItems[] = [
'product_name' => $item->product->name,
'old' => [
'counted_qty' => $oldQty,
'notes' => $oldNotes,
],
'new' => [
'counted_qty' => $newQty,
'notes' => $newNotes,
]
];
$countedQty = $data['counted_qty']; $countedQty = $data['counted_qty'];
$diff = is_numeric($countedQty) ? ($countedQty - $item->system_qty) : 0; $diff = is_numeric($countedQty) ? ($countedQty - $item->system_qty) : 0;
$item->update([ $item->update([
'counted_qty' => $countedQty, 'counted_qty' => $countedQty,
'diff_qty' => $diff, 'diff_qty' => $diff,
'notes' => $data['notes'] ?? $item->notes, 'notes' => $newNotes,
]); ]);
$hasChanges = true;
} }
} }
}
// 檢查是否完成
$doc->refresh();
$isAllCounted = $doc->items()->whereNull('counted_qty')->count() === 0;
$newDocAttributesLog = [];
if ($isAllCounted) {
// 檢查是否有任何差異
$hasDiff = $doc->items()->where('diff_qty', '!=', 0)->exists();
$targetStatus = $hasDiff ? 'completed' : 'no_adjust';
if ($doc->status !== $targetStatus) {
$doc->status = $targetStatus;
$doc->completed_at = now();
$doc->completed_by = auth()->id();
$doc->saveQuietly();
$doc->refresh();
$newDocAttributesLog = [
'status' => $targetStatus,
'completed_at' => $doc->completed_at->format('Y-m-d H:i:s'),
'completed_by' => $doc->completed_by,
];
$hasChanges = true;
}
} else {
if ($doc->status === 'completed') {
$doc->status = 'counting';
$doc->completed_at = null;
$doc->completed_by = null;
$doc->saveQuietly();
$newDocAttributesLog = [
'status' => 'counting',
'completed_at' => null,
'completed_by' => null,
];
$hasChanges = true;
}
}
// 記錄操作日誌
if ($hasChanges) {
$properties = [
'items_diff' => [
'added' => [],
'removed' => [],
'updated' => $updatedItems,
],
];
// 如果有文件層級的屬性變更 (狀態),併入 log
if (!empty($newDocAttributesLog)) {
$properties['attributes'] = $newDocAttributesLog;
$properties['old'] = array_intersect_key($oldDocAttributes, $newDocAttributesLog);
}
activity()
->performedOn($doc)
->causedBy(auth()->user())
->event('updated')
->withProperties($properties)
->log('updated');
}
}); });
} }
} }

View File

@@ -90,8 +90,8 @@ class GoodsReceiptService
private function generateCode(string $date) private function generateCode(string $date)
{ {
// Format: GR + YYYYMMDD + NNN // Format: GR-YYYYMMDD-NN
$prefix = 'GR' . date('Ymd', strtotime($date)); $prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
$last = GoodsReceipt::where('code', 'like', $prefix . '%') $last = GoodsReceipt::where('code', 'like', $prefix . '%')
->orderBy('id', 'desc') ->orderBy('id', 'desc')
@@ -99,11 +99,11 @@ class GoodsReceiptService
->first(); ->first();
if ($last) { if ($last) {
$seq = intval(substr($last->code, -3)) + 1; $seq = intval(substr($last->code, -2)) + 1;
} else { } else {
$seq = 1; $seq = 1;
} }
return $prefix . str_pad($seq, 3, '0', STR_PAD_LEFT); return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
} }
} }

View File

@@ -188,6 +188,14 @@ class InventoryService implements InventoryServiceInterface
}); });
} }
public function findInventoryByBatch(int $warehouseId, int $productId, ?string $batchNumber)
{
return Inventory::where('warehouse_id', $warehouseId)
->where('product_id', $productId)
->where('batch_number', $batchNumber)
->first();
}
public function getDashboardStats(): array public function getDashboardStats(): array
{ {
// 庫存總表 join 安全庫存表,計算低庫存 // 庫存總表 join 安全庫存表,計算低庫存

View File

@@ -28,19 +28,101 @@ class TransferService
/** /**
* 更新調撥單明細 * 更新調撥單明細
*/ */
public function updateItems(InventoryTransferOrder $order, array $itemsData): void /**
* 更新調撥單明細 (支援精確 Diff 與自動日誌整合)
*/
public function updateItems(InventoryTransferOrder $order, array $itemsData): bool
{ {
DB::transaction(function () use ($order, $itemsData) { return DB::transaction(function () use ($order, $itemsData) {
// 1. 準備舊資料索引 (Key: product_id . '_' . batch_number)
$oldItemsMap = $order->items->mapWithKeys(function ($item) {
$key = $item->product_id . '_' . ($item->batch_number ?? '');
return [$key => $item];
});
$diff = [
'added' => [],
'removed' => [],
'updated' => [],
];
// 2. 處理新資料 (Deleted and Re-inserted currently for simplicity, but logic simulates update)
// 為了保持 ID 當作外鍵的穩定性,最佳做法是 update 存在的create 新的delete 舊的。
// 但考量現有邏輯是 delete all -> create all我們維持原策略但優化 Diff 計算。
// 由於採用全刪重建,我們必須手動計算 Diff
$order->items()->delete(); $order->items()->delete();
$newItemsKeys = [];
foreach ($itemsData as $data) { foreach ($itemsData as $data) {
$order->items()->create([ $key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
$newItemsKeys[] = $key;
$item = $order->items()->create([
'product_id' => $data['product_id'], 'product_id' => $data['product_id'],
'batch_number' => $data['batch_number'] ?? null, 'batch_number' => $data['batch_number'] ?? null,
'quantity' => $data['quantity'], 'quantity' => $data['quantity'],
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
// Eager load product for name
$item->load('product');
// 比對邏輯
if ($oldItemsMap->has($key)) {
$oldItem = $oldItemsMap->get($key);
// 檢查數值是否有變動
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
$oldItem->notes !== ($data['notes'] ?? null)) {
$diff['updated'][] = [
'product_name' => $item->product->name,
'old' => [
'quantity' => (float)$oldItem->quantity,
'notes' => $oldItem->notes,
],
'new' => [
'quantity' => (float)$data['quantity'],
'notes' => $item->notes,
]
];
} }
} else {
// 新增 (使用者需求:顯示為更新,從 0 -> X)
$diff['updated'][] = [
'product_name' => $item->product->name,
'old' => [
'quantity' => 0,
'notes' => null,
],
'new' => [
'quantity' => (float)$item->quantity,
'notes' => $item->notes,
]
];
}
}
// 3. 處理被移除的項目
foreach ($oldItemsMap as $key => $oldItem) {
if (!in_array($key, $newItemsKeys)) {
$diff['removed'][] = [
'product_name' => $oldItem->product->name,
'old' => [
'quantity' => (float)$oldItem->quantity,
'notes' => $oldItem->notes,
]
];
}
}
// 4. 將 Diff 注入到 Model 的暫存屬性中
$hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
if ($hasChanged) {
$order->activityProperties['items_diff'] = $diff;
}
return $hasChanged;
}); });
} }
@@ -49,6 +131,9 @@ class TransferService
*/ */
public function post(InventoryTransferOrder $order, int $userId): void public function post(InventoryTransferOrder $order, int $userId): void
{ {
// [IMPORTANT] 強制重新載入品項,因為在 Controller 中可能剛執行過 updateItems導致記憶體中快取的 items 是舊的或空的
$order->load('items.product');
DB::transaction(function () use ($order, $userId) { DB::transaction(function () use ($order, $userId) {
$fromWarehouse = $order->fromWarehouse; $fromWarehouse = $order->fromWarehouse;
$toWarehouse = $order->toWarehouse; $toWarehouse = $order->toWarehouse;
@@ -71,6 +156,9 @@ class TransferService
$oldSourceQty = $sourceInventory->quantity; $oldSourceQty = $sourceInventory->quantity;
$newSourceQty = $oldSourceQty - $item->quantity; $newSourceQty = $oldSourceQty - $item->quantity;
// 儲存庫存快照
$item->update(['snapshot_quantity' => $oldSourceQty]);
$sourceInventory->quantity = $newSourceQty; $sourceInventory->quantity = $newSourceQty;
// 更新總值 (假設成本不變) // 更新總值 (假設成本不變)
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; $sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
@@ -131,11 +219,25 @@ class TransferService
]); ]);
} }
$order->update([ // 準備品項快照供日誌使用
'status' => 'completed', $itemsSnapshot = $order->items->map(function($item) {
'posted_at' => now(), return [
'posted_by' => $userId, 'product_name' => $item->product->name,
]); 'old' => [
'quantity' => (float)$item->quantity,
'notes' => $item->notes,
],
'new' => [
'quantity' => (float)$item->quantity,
'notes' => $item->notes,
]
];
})->toArray();
$order->status = 'completed';
$order->posted_at = now();
$order->posted_by = $userId;
$order->save(); // 觸發自動日誌
}); });
} }

View File

@@ -187,20 +187,20 @@ class PurchaseOrderController extends Controller
try { try {
DB::beginTransaction(); DB::beginTransaction();
// 生成單號POYYYYMMDD001 // 生成單號PO-YYYYMMDD-01
$today = now()->format('Ymd'); $today = now()->format('Ymd');
$prefix = 'PO' . $today; $prefix = 'PO-' . $today . '-';
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%') $lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
->lockForUpdate() // 鎖定以避免並發衝突 ->lockForUpdate() // 鎖定以避免並發衝突
->orderBy('code', 'desc') ->orderBy('code', 'desc')
->first(); ->first();
if ($lastOrder) { if ($lastOrder) {
// 取得最後 3 碼序號並加 1 // 取得最後 2 碼序號並加 1
$lastSequence = intval(substr($lastOrder->code, -3)); $lastSequence = intval(substr($lastOrder->code, -2));
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT); $sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
} else { } else {
$sequence = '001'; $sequence = '01';
} }
$code = $prefix . $sequence; $code = $prefix . $sequence;

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Modules\Procurement\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\ShippingOrder;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Core\Contracts\CoreServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class ShippingOrderController extends Controller
{
protected $inventoryService;
protected $coreService;
protected $shippingService;
public function __construct(
InventoryServiceInterface $inventoryService,
CoreServiceInterface $coreService,
\App\Modules\Procurement\Services\ShippingService $shippingService
) {
$this->inventoryService = $inventoryService;
$this->coreService = $coreService;
$this->shippingService = $shippingService;
}
public function index(Request $request)
{
return Inertia::render('Common/UnderConstruction', [
'featureName' => '出貨單管理'
]);
/* 原有邏輯暫存
$query = ShippingOrder::query();
// 搜尋
if ($request->search) {
$query->where(function($q) use ($request) {
$q->where('doc_no', 'like', "%{$request->search}%")
->orWhere('customer_name', 'like', "%{$request->search}%");
});
}
// 狀態篩選
if ($request->status && $request->status !== 'all') {
$query->where('status', $request->status);
}
$perPage = $request->input('per_page', 10);
$orders = $query->orderBy('id', 'desc')->paginate($perPage)->withQueryString();
// 水和倉庫與使用者
$warehouses = $this->inventoryService->getAllWarehouses();
$userIds = $orders->getCollection()->pluck('created_by')->filter()->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
$orders->getCollection()->transform(function ($order) use ($warehouses, $users) {
$order->warehouse_name = $warehouses->firstWhere('id', $order->warehouse_id)?->name ?? 'Unknown';
$order->creator_name = $users->get($order->created_by)?->name ?? 'System';
return $order;
});
return Inertia::render('ShippingOrder/Index', [
'orders' => $orders,
'filters' => $request->only(['search', 'status', 'per_page']),
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
]);
*/
}
public function create()
{
return Inertia::render('Common/UnderConstruction', [
'featureName' => '出貨單建立'
]);
/* 原有邏輯暫存
$warehouses = $this->inventoryService->getAllWarehouses();
$products = $this->inventoryService->getAllProducts();
return Inertia::render('ShippingOrder/Create', [
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
'products' => $products->map(fn($p) => [
'id' => $p->id,
'name' => $p->name,
'code' => $p->code,
'unit_name' => $p->baseUnit?->name,
]),
]);
*/
}
public function store(Request $request)
{
return back()->with('error', '出貨單管理功能正在製作中');
}
public function show($id)
{
return Inertia::render('Common/UnderConstruction', [
'featureName' => '出貨單詳情'
]);
}
public function edit($id)
{
return Inertia::render('Common/UnderConstruction', [
'featureName' => '出貨單編輯'
]);
}
public function update(Request $request, $id)
{
return back()->with('error', '出貨單管理功能正在製作中');
}
public function post($id)
{
return back()->with('error', '出貨單管理功能正在製作中');
}
public function destroy($id)
{
$order = ShippingOrder::findOrFail($id);
if ($order->status !== 'draft') {
return back()->withErrors(['error' => '僅能刪除草稿狀態的單據']);
}
$order->delete();
return redirect()->route('delivery-notes.index')->with('success', '出貨單已刪除');
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Modules\Procurement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Contracts\Activity;
class ShippingOrder extends Model
{
use HasFactory, LogsActivity;
protected $fillable = [
'doc_no',
'customer_name',
'warehouse_id',
'status',
'shipping_date',
'total_amount',
'tax_amount',
'grand_total',
'remarks',
'created_by',
'posted_by',
'posted_at',
];
protected $casts = [
'shipping_date' => 'date',
'posted_at' => 'datetime',
'total_amount' => 'decimal:2',
'tax_amount' => 'decimal:2',
'grand_total' => 'decimal:2',
];
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(Activity $activity, string $eventName)
{
$snapshot = $activity->properties['snapshot'] ?? [];
$snapshot['doc_no'] = $this->doc_no;
$snapshot['customer_name'] = $this->customer_name;
$activity->properties = $activity->properties->merge([
'snapshot' => $snapshot
]);
}
public function items()
{
return $this->hasMany(ShippingOrderItem::class);
}
/**
* 自動產生單號
*/
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->doc_no)) {
$today = date('Ymd');
$prefix = 'SHP-' . $today . '-';
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
->orderBy('doc_no', 'desc')
->first();
if ($lastDoc) {
$lastNumber = substr($lastDoc->doc_no, -2);
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
} else {
$nextNumber = '01';
}
$model->doc_no = $prefix . $nextNumber;
}
});
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Modules\Procurement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ShippingOrderItem extends Model
{
use HasFactory;
protected $fillable = [
'shipping_order_id',
'product_id',
'batch_number',
'quantity',
'unit_price',
'subtotal',
'remark',
];
protected $casts = [
'quantity' => 'decimal:4',
'unit_price' => 'decimal:4',
'subtotal' => 'decimal:2',
];
public function shippingOrder()
{
return $this->belongsTo(ShippingOrder::class);
}
// 注意:在模組化架構下,跨模組關聯應謹慎使用或是直接在 Controller 水和 (Hydration)
// 但為了開發便利,暫時保留對 Product 的關聯(如果 Product 在不同模組,可能無法直接 lazy load
}

View File

@@ -35,4 +35,24 @@ Route::middleware('auth')->group(function () {
Route::put('/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.update'); Route::put('/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.update');
Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy'); Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy');
}); });
// 出貨單管理 (Delivery Notes)
Route::middleware('permission:delivery_notes.view')->group(function () {
Route::get('/delivery-notes', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'index'])->name('delivery-notes.index');
Route::middleware('permission:delivery_notes.create')->group(function () {
Route::get('/delivery-notes/create', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'create'])->name('delivery-notes.create');
Route::post('/delivery-notes', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'store'])->name('delivery-notes.store');
});
Route::get('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'show'])->name('delivery-notes.show');
Route::middleware('permission:delivery_notes.edit')->group(function () {
Route::get('/delivery-notes/{id}/edit', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'edit'])->name('delivery-notes.edit');
Route::put('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'update'])->name('delivery-notes.update');
Route::post('/delivery-notes/{id}/post', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'post'])->name('delivery-notes.post');
});
Route::delete('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'destroy'])->middleware('permission:delivery_notes.delete')->name('delivery-notes.destroy');
});
}); });

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Modules\Procurement\Services;
use App\Modules\Procurement\Models\ShippingOrder;
use App\Modules\Procurement\Models\ShippingOrderItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Support\Facades\DB;
class ShippingService
{
protected $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
public function createShippingOrder(array $data)
{
return DB::transaction(function () use ($data) {
$order = ShippingOrder::create([
'warehouse_id' => $data['warehouse_id'],
'customer_name' => $data['customer_name'] ?? null,
'shipping_date' => $data['shipping_date'],
'status' => 'draft',
'remarks' => $data['remarks'] ?? null,
'created_by' => auth()->id(),
'total_amount' => $data['total_amount'] ?? 0,
'tax_amount' => $data['tax_amount'] ?? 0,
'grand_total' => $data['grand_total'] ?? 0,
]);
foreach ($data['items'] as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'batch_number' => $item['batch_number'] ?? null,
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'] ?? 0,
'subtotal' => $item['subtotal'] ?? ($item['quantity'] * ($item['unit_price'] ?? 0)),
'remark' => $item['remark'] ?? null,
]);
}
return $order;
});
}
public function updateShippingOrder(ShippingOrder $order, array $data)
{
return DB::transaction(function () use ($order, $data) {
$order->update([
'warehouse_id' => $data['warehouse_id'],
'customer_name' => $data['customer_name'] ?? null,
'shipping_date' => $data['shipping_date'],
'remarks' => $data['remarks'] ?? null,
'total_amount' => $data['total_amount'] ?? 0,
'tax_amount' => $data['tax_amount'] ?? 0,
'grand_total' => $data['grand_total'] ?? 0,
]);
// 簡單處理:刪除舊項目並新增
$order->items()->delete();
foreach ($data['items'] as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'batch_number' => $item['batch_number'] ?? null,
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'] ?? 0,
'subtotal' => $item['subtotal'] ?? ($item['quantity'] * ($item['unit_price'] ?? 0)),
'remark' => $item['remark'] ?? null,
]);
}
return $order;
});
}
public function post(ShippingOrder $order)
{
if ($order->status !== 'draft') {
throw new \Exception('該單據已過帳或已取消。');
}
return DB::transaction(function () use ($order) {
foreach ($order->items as $item) {
// 尋找對應的庫存紀錄
$inventory = $this->inventoryService->findInventoryByBatch(
$order->warehouse_id,
$item->product_id,
$item->batch_number
);
if (!$inventory || $inventory->quantity < $item->quantity) {
$productName = $this->inventoryService->getProduct($item->product_id)?->name ?? 'Unknown';
throw new \Exception("商品 [{$productName}] (批號: {$item->batch_number}) 庫存不足。");
}
// 扣除庫存
$this->inventoryService->decreaseInventoryQuantity(
$inventory->id,
$item->quantity,
"出貨扣款: 單號 [{$order->doc_no}]",
'ShippingOrder',
$order->id
);
}
$order->update([
'status' => 'completed',
'posted_by' => auth()->id(),
'posted_at' => now(),
]);
return $order;
});
}
}

View File

@@ -269,6 +269,33 @@ class ProductionOrderController extends Controller
return response()->json($data); return response()->json($data);
} }
/**
* 取得商品在各倉庫的庫存分佈
*/
public function getProductWarehouses($productId)
{
$inventories = \App\Modules\Inventory\Models\Inventory::with(['warehouse', 'product.baseUnit'])
->where('product_id', $productId)
->where('quantity', '>', 0)
->get();
$data = $inventories->map(function ($inv) {
return [
'id' => $inv->id, // Inventory ID
'warehouse_id' => $inv->warehouse_id,
'warehouse_name' => $inv->warehouse->name ?? '未知倉庫',
'batch_number' => $inv->batch_number,
'quantity' => $inv->quantity,
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
'unit_name' => $inv->product->baseUnit->name ?? '',
'base_unit_id' => $inv->product->base_unit_id ?? null,
'conversion_rate' => $inv->product->conversion_rate ?? 1,
];
});
return response()->json($data);
}
/** /**
* 編輯生產單 * 編輯生產單
*/ */

View File

@@ -30,6 +30,10 @@ Route::middleware('auth')->group(function () {
->middleware('permission:production_orders.create') ->middleware('permission:production_orders.create')
->name('api.production.warehouses.inventories'); ->name('api.production.warehouses.inventories');
Route::get('/api/production/products/{product}/inventories', [ProductionOrderController::class, 'getProductWarehouses'])
->middleware('permission:production_orders.create')
->name('api.production.products.inventories');
Route::get('/api/production/recipes/latest-by-product/{productId}', [RecipeController::class, 'getLatestByProduct']) Route::get('/api/production/recipes/latest-by-product/{productId}', [RecipeController::class, 'getLatestByProduct'])
->name('api.production.recipes.latest-by-product'); ->name('api.production.recipes.latest-by-product');

View File

@@ -4,10 +4,11 @@ use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\TrustProxies; use Illuminate\Http\Middleware\TrustProxies;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Spatie\Permission\Exceptions\UnauthorizedException; use Spatie\Permission\Exceptions\UnauthorizedException;
use Inertia\Inertia; use Inertia\Inertia;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@@ -37,14 +38,24 @@ return Application::configure(basePath: dirname(__DIR__))
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// 處理 Spatie Permission 的 UnauthorizedException // 處理 Spatie Permission 的 UnauthorizedException
$exceptions->render(function (UnauthorizedException $e) { $exceptions->render(function (UnauthorizedException $e) {
return Inertia::render('Error/403')->toResponse(request())->setStatusCode(403); return Inertia::render('Error/Index', ['status' => 403])
->toResponse(request())
->setStatusCode(403);
}); });
// 處理一般的 403 HttpException // 處理 404 NotFoundHttpException
$exceptions->render(function (NotFoundHttpException $e) {
return Inertia::render('Error/Index', ['status' => 404])
->toResponse(request())
->setStatusCode(404);
});
// 處理其他一般的 HttpException (包含 403, 419, 429, 500, 503 等)
$exceptions->render(function (HttpException $e) { $exceptions->render(function (HttpException $e) {
if ($e->getStatusCode() === 403) { $status = $e->getStatusCode();
return Inertia::render('Error/403')->toResponse(request())->setStatusCode(403); return Inertia::render('Error/Index', ['status' => $status])
} ->toResponse(request())
->setStatusCode($status);
}); });
})->create(); })->create();

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 刪除 purchase_orders.publish 權限
\Spatie\Permission\Models\Permission::where('name', 'purchase_orders.publish')->delete();
// 重置權限快取
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// 恢復權限(如果需要回滾)
\Spatie\Permission\Models\Permission::firstOrCreate(['name' => 'purchase_orders.publish']);
// 重新分配給 admin (簡單恢復,可能無法完全還原所有角色配置)
$admin = \Spatie\Permission\Models\Role::where('name', 'admin')->first();
if ($admin) {
$admin->givePermissionTo('purchase_orders.publish');
}
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('inventory_transfer_items', function (Blueprint $table) {
$table->decimal('snapshot_quantity', 10, 2)->nullable()->comment('過帳時的來源倉可用庫存快照')->after('quantity');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventory_transfer_items', function (Blueprint $table) {
$table->dropColumn('snapshot_quantity');
});
}
};

View File

@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('shipping_orders', function (Blueprint $table) {
$table->id();
$table->string('doc_no')->unique()->comment('出貨單號');
$table->string('customer_name')->nullable()->comment('客戶名稱');
$table->unsignedBigInteger('warehouse_id')->comment('來源倉庫');
$table->string('status')->default('draft')->comment('狀態: draft, completed, cancelled');
$table->date('shipping_date')->comment('出貨日期');
$table->decimal('total_amount', 15, 2)->default(0)->comment('總金額 (不含稅)');
$table->decimal('tax_amount', 15, 2)->default(0)->comment('稅額');
$table->decimal('grand_total', 15, 2)->default(0)->comment('總計');
$table->text('remarks')->nullable()->comment('備註');
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('posted_by')->nullable();
$table->timestamp('posted_at')->nullable();
$table->timestamps();
$table->index('warehouse_id');
$table->index('status');
$table->index('shipping_date');
});
Schema::create('shipping_order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('shipping_order_id')->constrained('shipping_orders')->onDelete('cascade');
$table->unsignedBigInteger('product_id')->comment('商品 ID');
$table->string('batch_number')->nullable()->comment('批號');
$table->decimal('quantity', 15, 4)->comment('出貨數量');
$table->decimal('unit_price', 15, 4)->default(0)->comment('單價');
$table->decimal('subtotal', 15, 2)->default(0)->comment('小計');
$table->string('remark')->nullable()->comment('項目備註');
$table->timestamps();
$table->index('product_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('shipping_order_items');
Schema::dropIfExists('shipping_orders');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->decimal('cost_price', 12, 2)->nullable()->after('location');
$table->decimal('price', 12, 2)->nullable()->after('cost_price');
$table->decimal('member_price', 12, 2)->nullable()->after('price');
$table->decimal('wholesale_price', 12, 2)->nullable()->after('member_price');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn(['cost_price', 'price', 'member_price', 'wholesale_price']);
});
}
};

View File

@@ -30,7 +30,7 @@ class PermissionSeeder extends Seeder
'purchase_orders.create', 'purchase_orders.create',
'purchase_orders.edit', 'purchase_orders.edit',
'purchase_orders.delete', 'purchase_orders.delete',
'purchase_orders.publish',
// 庫存管理 // 庫存管理
'inventory.view', 'inventory.view',
@@ -61,6 +61,12 @@ class PermissionSeeder extends Seeder
'goods_receipts.edit', 'goods_receipts.edit',
'goods_receipts.delete', 'goods_receipts.delete',
// 出貨單管理 (Delivery Notes / Shipping Orders)
'delivery_notes.view',
'delivery_notes.create',
'delivery_notes.edit',
'delivery_notes.delete',
// 生產工單管理 // 生產工單管理
'production_orders.view', 'production_orders.view',
'production_orders.create', 'production_orders.create',
@@ -132,12 +138,13 @@ class PermissionSeeder extends Seeder
$admin->givePermissionTo([ $admin->givePermissionTo([
'products.view', 'products.create', 'products.edit', 'products.delete', 'products.view', 'products.create', 'products.edit', 'products.delete',
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit', 'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
'purchase_orders.delete', 'purchase_orders.publish', 'purchase_orders.delete',
'inventory.view', 'inventory.view_cost', 'inventory.delete', 'inventory.view', 'inventory.view_cost', 'inventory.delete',
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete', 'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete',
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.delete',
'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.delete', 'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.delete',
'recipes.view', 'recipes.create', 'recipes.edit', 'recipes.delete', 'recipes.view', 'recipes.create', 'recipes.edit', 'recipes.delete',
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete', 'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',

View File

@@ -97,6 +97,7 @@ const fieldLabels: Record<string, string> = {
source_purchase_order_id: '來源採購單', source_purchase_order_id: '來源採購單',
quality_status: '品質狀態', quality_status: '品質狀態',
quality_remark: '品質備註', quality_remark: '品質備註',
purchase_order_id: '來源採購單',
// 採購單欄位 // 採購單欄位
po_number: '採購單號', po_number: '採購單號',
vendor_id: '廠商', vendor_id: '廠商',
@@ -105,6 +106,7 @@ const fieldLabels: Record<string, string> = {
user_id: '建單人員', user_id: '建單人員',
total_amount: '小計', total_amount: '小計',
expected_delivery_date: '預計到貨日', expected_delivery_date: '預計到貨日',
order_date: '下單日期',
status: '狀態', status: '狀態',
tax_amount: '稅額', tax_amount: '稅額',
grand_total: '總計', grand_total: '總計',
@@ -129,6 +131,24 @@ const fieldLabels: Record<string, string> = {
recipe_id: '生產配方', recipe_id: '生產配方',
recipe_name: '配方名稱', recipe_name: '配方名稱',
yield_quantity: '預期產量', yield_quantity: '預期產量',
// 庫存單據通用欄位
doc_no: '單據編號',
snapshot_date: '快照日期',
completed_at: '完成日期',
posted_at: '過帳日期',
from_warehouse_id: '來源倉庫',
from_warehouse_name: '來源倉庫名稱',
to_warehouse_id: '目的地倉庫',
to_warehouse_name: '目的地倉庫名稱',
reason: '原因',
count_doc_id: '盤點單 ID',
count_doc_no: '盤點單號',
created_by: '建立者',
updated_by: '更新者',
completed_by: '完成者',
posted_by: '過帳者',
counted_qty: '盤點數量',
adjust_qty: '調整數量',
}; };
// 狀態翻譯對照表 // 狀態翻譯對照表
@@ -141,9 +161,18 @@ const statusMap: Record<string, string> = {
received: '已收貨', received: '已收貨',
cancelled: '已取消', cancelled: '已取消',
completed: '已完成', completed: '已完成',
closed: '已結案',
partial: '部分收貨',
// 庫存單據狀態
counting: '盤點中',
posted: '已過帳',
no_adjust: '無需盤調',
adjusted: '已盤調',
// 生產工單狀態 // 生產工單狀態
planned: '已計畫', planned: '已計畫',
in_progress: '生產中', in_progress: '生產中',
// 調撥單狀態
voided: '已作廢',
// completed 已定義 // completed 已定義
}; };
@@ -154,6 +183,13 @@ const qualityStatusMap: Record<string, string> = {
rejected: '瑕疵/拒收', rejected: '瑕疵/拒收',
}; };
// 入庫類型翻譯對照表
const typeMap: Record<string, string> = {
standard: '採購進貨',
miscellaneous: '雜項入庫',
other: '其他入庫',
};
export default function ActivityDetailDialog({ open, onOpenChange, activity }: Props) { export default function ActivityDetailDialog({ open, onOpenChange, activity }: Props) {
if (!activity) return null; if (!activity) return null;
@@ -166,7 +202,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
// 自訂欄位排序順序 // 自訂欄位排序順序
const sortOrder = [ const sortOrder = [
'po_number', 'vendor_name', 'warehouse_name', 'expected_delivery_date', 'status', 'remark', 'po_number', 'vendor_name', 'warehouse_name', 'order_date', 'expected_delivery_date', 'status', 'remark',
'invoice_number', 'invoice_date', 'invoice_amount', 'invoice_number', 'invoice_date', 'invoice_amount',
'total_amount', 'tax_amount', 'grand_total' // 確保金額的特定順序 'total_amount', 'tax_amount', 'grand_total' // 確保金額的特定順序
]; ];
@@ -189,12 +225,21 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
return a.localeCompare(b); return a.localeCompare(b);
}); });
// 檢查鍵是否為快照名稱欄位的輔助函式 // 檢查鍵是否為快照名稱欄位或輔助名稱欄位的輔助函式
const isSnapshotField = (key: string) => { const isSnapshotField = (key: string) => {
return [ // 隱藏快照欄位
const snapshotFields = [
'category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name', 'category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name',
'warehouse_name', 'user_name' 'warehouse_name', 'user_name', 'from_warehouse_name', 'to_warehouse_name',
].includes(key); 'created_by_name', 'updated_by_name', 'completed_by_name', 'posted_by_name'
];
if (snapshotFields.includes(key)) return true;
// 隱藏所有以 _name 結尾的欄位(因為它們通常是 ID 欄位的文字補充)
if (key.endsWith('_name')) return true;
return false;
}; };
const getEventBadgeClass = (event: string) => { const getEventBadgeClass = (event: string) => {
@@ -234,12 +279,36 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
return qualityStatusMap[value]; return qualityStatusMap[value];
} }
// 處理入庫類型
if (key === 'type' && typeof value === 'string' && typeMap[value]) {
return typeMap[value];
}
// 處理日期欄位 (YYYY-MM-DD) // 處理日期欄位 (YYYY-MM-DD)
if ((key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date') && typeof value === 'string') { if ((key === 'order_date' || key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date') && typeof value === 'string') {
// 僅取日期部分 (YYYY-MM-DD) // 僅取日期部分 (YYYY-MM-DD)
return value.split('T')[0].split(' ')[0]; return value.split('T')[0].split(' ')[0];
} }
// 處理日期時間欄位 (YYYY-MM-DD HH:mm:ss)
if ((key === 'snapshot_date' || key === 'completed_at' || key === 'posted_at') && typeof value === 'string') {
try {
const date = new Date(value);
return date.toLocaleString('zh-TW', {
timeZone: 'Asia/Taipei',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).replace(/\//g, '-');
} catch (e) {
return value;
}
}
return String(value); return String(value);
}; };
@@ -270,7 +339,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
return `${wName} - ${pName}`; return `${wName} - ${pName}`;
} }
const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title']; const nameParams = ['doc_no', 'po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title'];
for (const param of nameParams) { for (const param of nameParams) {
if (snapshot[param]) return snapshot[param]; if (snapshot[param]) return snapshot[param];
if (attributes[param]) return attributes[param]; if (attributes[param]) return attributes[param];
@@ -285,7 +354,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto p-0 gap-0"> <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto p-0 gap-0">
<DialogHeader className="p-6 pb-4 border-b pr-12"> <DialogHeader className="p-6 pb-4 border-b pr-12">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<DialogTitle className="text-xl font-bold text-gray-900"> <DialogTitle className="text-xl font-bold text-gray-900">
@@ -327,12 +396,12 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
<div className="bg-gray-50/50 p-6 min-h-[300px]"> <div className="bg-gray-50/50 p-6 min-h-[300px]">
{activity.event === 'created' ? ( {activity.event === 'created' ? (
<div className="border rounded-md overflow-hidden bg-white shadow-sm"> <div className="border rounded-md overflow-hidden bg-white shadow-sm">
<Table> <Table className="table-fixed w-full">
<TableHeader> <TableHeader>
<TableRow className="bg-gray-50/50 hover:bg-gray-50/50"> <TableRow className="bg-gray-50/50 hover:bg-gray-50/50">
<TableHead className="w-[150px]"></TableHead> <TableHead className="w-[140px]"></TableHead>
<TableHead></TableHead> <TableHead className="w-1/2"></TableHead>
<TableHead></TableHead> <TableHead className="w-1/2"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -340,9 +409,9 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
.filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key)) .filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key))
.map((key) => ( .map((key) => (
<TableRow key={key}> <TableRow key={key}>
<TableCell className="font-medium text-gray-700 w-[120px] shrink-0">{getFieldLabel(key)}</TableCell> <TableCell className="font-medium text-gray-700 w-[140px] truncate">{getFieldLabel(key)}</TableCell>
<TableCell className="text-gray-500 break-all min-w-[150px]">-</TableCell> <TableCell className="text-gray-500">-</TableCell>
<TableCell className="text-gray-900 font-medium break-all min-w-[200px] whitespace-pre-wrap"> <TableCell className="text-gray-900 font-medium break-all whitespace-pre-wrap">
{getFormattedValue(key, attributes[key])} {getFormattedValue(key, attributes[key])}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -359,12 +428,12 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</div> </div>
) : ( ) : (
<div className="border rounded-md overflow-hidden bg-white shadow-sm"> <div className="border rounded-md overflow-hidden bg-white shadow-sm">
<Table> <Table className="table-fixed w-full">
<TableHeader> <TableHeader>
<TableRow className="bg-gray-50 hover:bg-gray-50"> <TableRow className="bg-gray-50 hover:bg-gray-50">
<TableHead className="w-[150px]"></TableHead> <TableHead className="w-[140px]"></TableHead>
<TableHead></TableHead> <TableHead className="w-1/2"></TableHead>
<TableHead></TableHead> <TableHead className="w-1/2"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -398,11 +467,11 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
return ( return (
<TableRow key={key} className={isChanged ? 'bg-amber-50/30 hover:bg-amber-50/50' : 'hover:bg-gray-50/50'}> <TableRow key={key} className={isChanged ? 'bg-amber-50/30 hover:bg-amber-50/50' : 'hover:bg-gray-50/50'}>
<TableCell className="font-medium text-gray-700 w-[120px] shrink-0">{getFieldLabel(key)}</TableCell> <TableCell className="font-medium text-gray-700 w-[140px] truncate">{getFieldLabel(key)}</TableCell>
<TableCell className="text-gray-500 break-all min-w-[150px] whitespace-pre-wrap"> <TableCell className="text-gray-500 break-all whitespace-pre-wrap">
{displayBefore} {displayBefore}
</TableCell> </TableCell>
<TableCell className="text-gray-900 font-medium break-all min-w-[200px] whitespace-pre-wrap"> <TableCell className="text-gray-900 font-medium break-all whitespace-pre-wrap">
{displayAfter} {displayAfter}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -428,17 +497,17 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</h3> </h3>
<div className="border rounded-md overflow-hidden bg-white shadow-sm"> <div className="border rounded-md overflow-hidden bg-white shadow-sm">
<Table> <Table className="table-fixed w-full">
<TableHeader className="bg-gray-50/50"> <TableHeader className="bg-gray-50/50">
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead className="w-1/3"></TableHead>
<TableHead className="text-center"></TableHead> <TableHead className="w-[100px] text-center"></TableHead>
<TableHead> ( )</TableHead> <TableHead className="w-1/2"> ( )</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{/* 更新項目 */} {/* 更新項目 */}
{activity.properties.items_diff.updated.map((item: any, idx: number) => ( {activity.properties.items_diff.updated?.map((item: any, idx: number) => (
<TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20"> <TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell> <TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
@@ -446,35 +515,46 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</TableCell> </TableCell>
<TableCell className="text-sm"> <TableCell className="text-sm">
<div className="space-y-1"> <div className="space-y-1">
{item.old.quantity !== item.new.quantity && ( {item.old?.quantity !== item.new?.quantity && item.old?.quantity !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.quantity}</span> <span className="text-blue-700 font-bold">{item.new.quantity}</span></div> <div>: <span className="text-gray-500 line-through">{item.old.quantity}</span> <span className="text-blue-700 font-bold">{item.new.quantity}</span></div>
)} )}
{item.old.unit_name !== item.new.unit_name && ( {item.old?.counted_qty !== item.new?.counted_qty && item.old?.counted_qty !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.counted_qty ?? '未盤'}</span> <span className="text-blue-700 font-bold">{item.new.counted_qty ?? '未盤'}</span></div>
)}
{item.old?.adjust_qty !== item.new?.adjust_qty && (
<div>調: <span className="text-gray-500 line-through">{item.old?.adjust_qty ?? '0'}</span> <span className="text-blue-700 font-bold">{item.new?.adjust_qty ?? '0'}</span></div>
)}
{item.old?.unit_name !== item.new?.unit_name && item.old?.unit_name !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.unit_name || '-'}</span> <span className="text-blue-700 font-bold">{item.new.unit_name || '-'}</span></div> <div>: <span className="text-gray-500 line-through">{item.old.unit_name || '-'}</span> <span className="text-blue-700 font-bold">{item.new.unit_name || '-'}</span></div>
)} )}
{item.old.subtotal !== item.new.subtotal && ( {item.old?.subtotal !== item.new?.subtotal && item.old?.subtotal !== undefined && (
<div>: <span className="text-gray-500 line-through">${item.old.subtotal}</span> <span className="text-blue-700 font-bold">${item.new.subtotal}</span></div> <div>: <span className="text-gray-500 line-through">${item.old.subtotal}</span> <span className="text-blue-700 font-bold">${item.new.subtotal}</span></div>
)} )}
{item.old?.notes !== item.new?.notes && (
<div>: <span className="text-gray-500 line-through">{item.old?.notes || '-'}</span> <span className="text-blue-700 font-bold">{item.new?.notes || '-'}</span></div>
)}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} )) || null}
{/* 新增項目 */} {/* 新增項目 */}
{activity.properties.items_diff.added.map((item: any, idx: number) => ( {activity.properties.items_diff.added?.map((item: any, idx: number) => (
<TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20"> <TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell> <TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200"></Badge> <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200"></Badge>
</TableCell> </TableCell>
<TableCell className="text-sm"> <TableCell className="text-sm">
: {item.quantity} {item.unit_name} / 小計: ${item.subtotal} {item.quantity !== undefined ? `數量: ${item.quantity} ${item.unit_name || ''} / ` : ''}
{item.adjust_qty !== undefined ? `調整量: ${item.adjust_qty} / ` : ''}
{item.subtotal !== undefined ? `小計: $${item.subtotal}` : ''}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} )) || null}
{/* 移除項目 */} {/* 移除項目 */}
{activity.properties.items_diff.removed.map((item: any, idx: number) => ( {activity.properties.items_diff.removed?.map((item: any, idx: number) => (
<TableRow key={`rem-${idx}`} className="bg-red-50/10 hover:bg-red-50/20"> <TableRow key={`rem-${idx}`} className="bg-red-50/10 hover:bg-red-50/20">
<TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell> <TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
@@ -484,7 +564,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
: {item.quantity} {item.unit_name} : {item.quantity} {item.unit_name}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} )) || null}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import LogTable, { Activity } from './LogTable';
import ActivityDetailDialog from './ActivityDetailDialog';
import { History } from 'lucide-react';
interface Props {
activities: Activity[];
className?: string;
}
export default function ActivityLog({ activities, className = '' }: Props) {
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const handleViewDetail = (activity: Activity) => {
setSelectedActivity(activity);
setIsDetailOpen(true);
};
return (
<div className={`space-y-4 ${className}`}>
<div className="flex items-center gap-2">
<History className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900"></h3>
</div>
<LogTable
activities={activities}
onViewDetail={handleViewDetail}
/>
<ActivityDetailDialog
open={isDetailOpen}
onOpenChange={setIsDetailOpen}
activity={selectedActivity}
/>
</div>
);
}

View File

@@ -63,7 +63,7 @@ export default function LogTable({
// 嘗試在快照、屬性或舊值中尋找名稱 // 嘗試在快照、屬性或舊值中尋找名稱
// 優先順序:快照 > 特定名稱欄位 > 通用名稱 > 代碼 > ID // 優先順序:快照 > 特定名稱欄位 > 通用名稱 > 代碼 > ID
const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title', 'category']; const nameParams = ['doc_no', 'po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title', 'category'];
let subjectName = ''; let subjectName = '';
// 庫存的特殊處理:顯示 "倉庫 - 商品" // 庫存的特殊處理:顯示 "倉庫 - 商品"

View File

@@ -0,0 +1,161 @@
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import { Switch } from '@/Components/ui/switch';
import { RefreshCcw, Scan, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ScannerInputProps {
onScan: (code: string, mode: 'continuous' | 'single') => void;
className?: string;
placeholder?: string;
}
export default function ScannerInput({ onScan, className, placeholder = "點擊此處並掃描..." }: ScannerInputProps) {
const [code, setCode] = useState('');
const [isContinuous, setIsContinuous] = useState(true);
const [lastAction, setLastAction] = useState<{ message: string; type: 'success' | 'info' | 'error'; time: number } | null>(null);
const [isFlashing, setIsFlashing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Focus input on mount
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
// Audio context for beep sound
const playBeep = (type: 'success' | 'error' = 'success') => {
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioContext) return;
const ctx = new AudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
if (type === 'success') {
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1);
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
oscillator.start();
oscillator.stop(ctx.currentTime + 0.1);
} else {
oscillator.type = 'sawtooth';
oscillator.frequency.setValueAtTime(110, ctx.currentTime); // Low buzz
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
oscillator.start();
oscillator.stop(ctx.currentTime + 0.2);
}
} catch (e) {
console.error('Audio playback failed', e);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
if (code.trim()) {
handleScanSubmit(code.trim());
}
}
};
const handleScanSubmit = (scannedCode: string) => {
// Trigger parent callback
onScan(scannedCode, isContinuous ? 'continuous' : 'single');
// UI Feedback
playBeep('success');
setIsFlashing(true);
setTimeout(() => setIsFlashing(false), 300);
// Show last action tip
setLastAction({
message: `已掃描: ${scannedCode}`,
type: 'success',
time: Date.now()
});
// Clear input and focus
setCode('');
};
// Public method to set last action message from parent (if needed for more context like product name)
// For now we just use internal state
return (
<div className={cn("bg-white p-4 rounded-xl border-2 shadow-sm transition-all relative overflow-hidden", isFlashing ? "border-green-500 bg-green-50" : "border-primary/20", className)}>
{/* Background flashy effect */}
<div className={cn("absolute inset-0 bg-green-400/20 transition-opacity duration-300 pointer-events-none", isFlashing ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col md:flex-row gap-4 items-center justify-between relative z-10">
{/* Left: Input Area */}
<div className="flex-1 w-full relative">
<Scan className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 h-5 w-5" />
<Input
ref={inputRef}
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="pl-10 h-12 text-lg font-mono border-gray-300 focus:border-primary focus:ring-primary/20"
/>
{/* Continuous Mode Badge */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
{isContinuous && (
<div className="bg-primary/10 text-primary text-xs font-bold px-2 py-1 rounded-md flex items-center gap-1">
<Zap className="h-3 w-3 fill-primary" />
</div>
)}
</div>
</div>
{/* Right: Controls & Status */}
<div className="flex items-center gap-6 w-full md:w-auto justify-between md:justify-end">
{/* Last Action Display */}
<div className="flex-1 md:flex-none text-right min-h-[40px] flex flex-col justify-center">
{lastAction && (Date.now() - lastAction.time < 5000) ? (
<div className="animate-in fade-in slide-in-from-right-4 duration-300">
<span className="text-sm font-bold text-gray-800 block">{lastAction.message}</span>
{isContinuous && <span className="text-xs text-green-600 font-bold block"> (+1)</span>}
</div>
) : (
<span className="text-gray-400 text-sm">...</span>
)}
</div>
<div className="h-8 w-px bg-gray-200 hidden md:block"></div>
{/* Toggle */}
<div className="flex items-center gap-2">
<Label htmlFor="continuous-mode" className={cn("text-sm font-bold cursor-pointer select-none", isContinuous ? "text-primary" : "text-gray-500")}>
</Label>
<Switch
id="continuous-mode"
checked={isContinuous}
onCheckedChange={setIsContinuous}
/>
</div>
</div>
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-gray-400 px-1">
<RefreshCcw className="h-3 w-3" />
<span> +1</span>
</div>
</div>
);
}

View File

@@ -47,6 +47,10 @@ export default function ProductDialog({
conversion_rate: "", conversion_rate: "",
purchase_unit_id: "", purchase_unit_id: "",
location: "", location: "",
cost_price: "",
price: "",
member_price: "",
wholesale_price: "",
}); });
useEffect(() => { useEffect(() => {
@@ -65,6 +69,10 @@ export default function ProductDialog({
conversion_rate: product.conversionRate ? product.conversionRate.toString() : "", conversion_rate: product.conversionRate ? product.conversionRate.toString() : "",
purchase_unit_id: product.purchaseUnitId?.toString() || "", purchase_unit_id: product.purchaseUnitId?.toString() || "",
location: product.location || "", location: product.location || "",
cost_price: product.cost_price?.toString() || "",
price: product.price?.toString() || "",
member_price: product.member_price?.toString() || "",
wholesale_price: product.wholesale_price?.toString() || "",
}); });
} else { } else {
reset(); reset();
@@ -235,6 +243,72 @@ export default function ProductDialog({
</div> </div>
</div> </div>
{/* 價格設定區塊 */}
<div className="space-y-4">
<h3 className="text-lg font-medium border-b pb-2"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cost_price"></Label>
<Input
id="cost_price"
type="number"
min="0"
step="any"
value={data.cost_price}
onChange={(e) => setData("cost_price", e.target.value)}
placeholder="0"
className={errors.cost_price ? "border-red-500" : ""}
/>
{errors.cost_price && <p className="text-sm text-red-500">{errors.cost_price}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="price"></Label>
<Input
id="price"
type="number"
min="0"
step="any"
value={data.price}
onChange={(e) => setData("price", e.target.value)}
placeholder="0"
className={errors.price ? "border-red-500" : ""}
/>
{errors.price && <p className="text-sm text-red-500">{errors.price}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="member_price"></Label>
<Input
id="member_price"
type="number"
min="0"
step="any"
value={data.member_price}
onChange={(e) => setData("member_price", e.target.value)}
placeholder="0"
className={errors.member_price ? "border-red-500" : ""}
/>
{errors.member_price && <p className="text-sm text-red-500">{errors.member_price}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="wholesale_price"></Label>
<Input
id="wholesale_price"
type="number"
min="0"
step="any"
value={data.wholesale_price}
onChange={(e) => setData("wholesale_price", e.target.value)}
placeholder="0"
className={errors.wholesale_price ? "border-red-500" : ""}
/>
{errors.wholesale_price && <p className="text-sm text-red-500">{errors.wholesale_price}</p>}
</div>
</div>
</div>
{/* 單位設定區塊 */} {/* 單位設定區塊 */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium border-b pb-2"></h3> <h3 className="text-lg font-medium border-b pb-2"></h3>
@@ -278,7 +352,7 @@ export default function ProductDialog({
<Input <Input
id="conversion_rate" id="conversion_rate"
type="number" type="number"
step="0.0001" step="any"
value={data.conversion_rate} value={data.conversion_rate}
onChange={(e) => setData("conversion_rate", e.target.value)} onChange={(e) => setData("conversion_rate", e.target.value)}
placeholder={data.large_unit_id && data.base_unit_id ? `1 ${units.find(u => u.id.toString() === data.large_unit_id)?.name} = ? ${units.find(u => u.id.toString() === data.base_unit_id)?.name}` : ""} placeholder={data.large_unit_id && data.base_unit_id ? `1 ${units.find(u => u.id.toString() === data.large_unit_id)?.name} = ? ${units.find(u => u.id.toString() === data.base_unit_id)?.name}` : ""}

View File

@@ -110,13 +110,13 @@ export function PurchaseOrderItemsTable({
<Input <Input
type="number" type="number"
min="0" min="0"
step="1" step="any"
value={item.quantity === 0 ? "" : Math.floor(item.quantity)} value={item.quantity === 0 ? "" : Math.floor(item.quantity)}
onChange={(e) => onChange={(e) =>
onItemChange?.(index, "quantity", Math.floor(Number(e.target.value))) onItemChange?.(index, "quantity", Number(e.target.value))
} }
disabled={isDisabled} disabled={isDisabled}
className="text-left w-24" className="text-right w-24"
/> />
)} )}
</TableCell> </TableCell>
@@ -189,13 +189,13 @@ export function PurchaseOrderItemsTable({
<Input <Input
type="number" type="number"
min="0" min="0"
step="1" step="any"
value={item.subtotal || ""} value={item.subtotal || ""}
onChange={(e) => onChange={(e) =>
onItemChange?.(index, "subtotal", Number(e.target.value)) onItemChange?.(index, "subtotal", Number(e.target.value))
} }
disabled={isDisabled} disabled={isDisabled}
className={`text-left w-32 ${ className={`text-right w-32 ${
// 如果有數量但沒有金額,顯示錯誤樣式 // 如果有數量但沒有金額,顯示錯誤樣式
item.quantity > 0 && (!item.subtotal || item.subtotal <= 0) item.quantity > 0 && (!item.subtotal || item.subtotal <= 0)
? "border-red-400 bg-red-50 focus-visible:ring-red-500" ? "border-red-400 bg-red-50 focus-visible:ring-red-500"

View File

@@ -78,6 +78,7 @@ export default function EditSafetyStockDialog({
id="safetyStock" id="safetyStock"
type="number" type="number"
min="1" min="1"
step="any"
value={safetyStock} value={safetyStock}
onChange={(e) => setSafetyStock(parseInt(e.target.value) || 0)} onChange={(e) => setSafetyStock(parseInt(e.target.value) || 0)}
placeholder="請輸入安全庫存量" placeholder="請輸入安全庫存量"

View File

@@ -172,7 +172,7 @@ export default function UtilityFeeDialog({
<Input <Input
id="amount" id="amount"
type="number" type="number"
step="0.01" step="any"
value={data.amount} value={data.amount}
onChange={(e) => setData("amount", e.target.value)} onChange={(e) => setData("amount", e.target.value)}
placeholder="0.00" placeholder="0.00"

View File

@@ -159,6 +159,8 @@ export default function AddSupplyProductDialog({
</Label> </Label>
<Input <Input
type="number" type="number"
min="0"
step="any"
placeholder="輸入價格" placeholder="輸入價格"
value={lastPrice} value={lastPrice}
onChange={(e) => setLastPrice(e.target.value)} onChange={(e) => setLastPrice(e.target.value)}

View File

@@ -86,6 +86,8 @@ export default function EditSupplyProductDialog({
<Label className="text-muted-foreground text-xs"> / {product.baseUnit || "單位"}</Label> <Label className="text-muted-foreground text-xs"> / {product.baseUnit || "單位"}</Label>
<Input <Input
type="number" type="number"
min="0"
step="any"
placeholder="輸入價格" placeholder="輸入價格"
value={lastPrice} value={lastPrice}
onChange={(e) => setLastPrice(e.target.value)} onChange={(e) => setLastPrice(e.target.value)}

View File

@@ -123,7 +123,7 @@ export default function BatchAdjustmentModal({
<Input <Input
id="adj-qty" id="adj-qty"
type="number" type="number"
step="0.01" step="any"
min="0" min="0"
value={quantity} value={quantity}
onChange={(e) => setQuantity(e.target.value)} onChange={(e) => setQuantity(e.target.value)}

View File

@@ -147,7 +147,7 @@ export default function InventoryAdjustmentDialog({
<Input <Input
id="quantity" id="quantity"
type="number" type="number"
step="0.01" step="any"
value={data.quantity === 0 ? "" : data.quantity} value={data.quantity === 0 ? "" : data.quantity}
onChange={e => setData("quantity", Number(e.target.value))} onChange={e => setData("quantity", Number(e.target.value))}
placeholder="請輸入數量" placeholder="請輸入數量"

View File

@@ -4,7 +4,7 @@
*/ */
import { useState } from "react"; import { useState } from "react";
import { AlertTriangle, Edit, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react"; import { AlertTriangle, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
import { import {
Table, Table,
TableBody, TableBody,
@@ -28,13 +28,12 @@ import {
import { GroupedInventory } from "@/types/warehouse"; import { GroupedInventory } from "@/types/warehouse";
import { formatDate } from "@/utils/format"; import { formatDate } from "@/utils/format";
import { Can } from "@/Components/Permission/Can"; import { Can } from "@/Components/Permission/Can";
import BatchAdjustmentModal from "./BatchAdjustmentModal";
interface InventoryTableProps { interface InventoryTableProps {
inventories: GroupedInventory[]; inventories: GroupedInventory[];
onView: (id: string) => void; onView: (id: string) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onAdjust: (batchId: string, data: { operation: string; quantity: number; reason: string }) => void;
onViewProduct?: (productId: string) => void; onViewProduct?: (productId: string) => void;
} }
@@ -42,19 +41,12 @@ export default function InventoryTable({
inventories, inventories,
onView, onView,
onDelete, onDelete,
onAdjust,
onViewProduct, onViewProduct,
}: InventoryTableProps) { }: InventoryTableProps) {
// 每個商品的展開/折疊狀態 // 每個商品的展開/折疊狀態
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set()); const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
// 調整彈窗狀態
const [adjustmentTarget, setAdjustmentTarget] = useState<{
id: string;
batchNumber: string;
currentQuantity: number;
productName: string;
} | null>(null);
if (inventories.length === 0) { if (inventories.length === 0) {
return ( return (
@@ -244,22 +236,7 @@ export default function InventoryTable({
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
<Can permission="inventory.adjust">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdjustmentTarget({
id: batch.id,
batchNumber: batch.batchNumber,
currentQuantity: batch.quantity,
productName: group.productName
})}
className="button-outlined-primary"
>
<Edit className="h-4 w-4" />
</Button>
</Can>
<Can permission="inventory.delete"> <Can permission="inventory.delete">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -302,17 +279,7 @@ export default function InventoryTable({
); );
})} })}
<BatchAdjustmentModal
isOpen={!!adjustmentTarget}
onClose={() => setAdjustmentTarget(null)}
batch={adjustmentTarget || undefined}
onConfirm={(data) => {
if (adjustmentTarget) {
onAdjust(adjustmentTarget.id, data);
setAdjustmentTarget(null);
}
}}
/>
</div> </div>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -231,7 +231,7 @@ export default function AddSafetyStockDialog({
<Input <Input
type="number" type="number"
min="0" min="0"
step="1" step="any"
value={quantity || ""} value={quantity || ""}
onChange={(e) => onChange={(e) =>
updateQuantity(productId, parseFloat(e.target.value) || 0) updateQuantity(productId, parseFloat(e.target.value) || 0)

View File

@@ -62,7 +62,7 @@ export default function EditSafetyStockDialog({
id="edit-safety" id="edit-safety"
type="number" type="number"
min="0" min="0"
step="1" step="any"
value={safetyStock} value={safetyStock}
onChange={(e) => setSafetyStock(parseFloat(e.target.value) || 0)} onChange={(e) => setSafetyStock(parseFloat(e.target.value) || 0)}
className="button-outlined-primary" className="button-outlined-primary"

View File

@@ -92,7 +92,7 @@ export function SearchableSelect({
<PopoverContent <PopoverContent
className="p-0 z-[9999]" className="p-0 z-[9999]"
align="start" align="start"
style={{ width: "var(--radix-popover-trigger-width)" }} style={{ width: "var(--radix-popover-trigger-width)", minWidth: "12rem" }}
> >
<Command> <Command>
{shouldShowSearch && ( {shouldShowSearch && (

View File

@@ -150,13 +150,13 @@ export default function AuthenticatedLayout({
route: "/goods-receipts", route: "/goods-receipts",
permission: "goods_receipts.view", permission: "goods_receipts.view",
}, },
// { {
// id: "delivery-note-list", id: "delivery-note-list",
// label: "出貨單管理 (開發中)", label: "出貨單管理 (功能製作中)",
// icon: <Package className="h-4 w-4" />, icon: <Package className="h-4 w-4" />,
// // route: "/delivery-notes", route: "/delivery-notes",
// permission: "delivery_notes.view", permission: "delivery_notes.view",
// }, },
], ],
}, },
{ {

View File

@@ -113,7 +113,7 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
setPerPage(value); setPerPage(value);
router.get( router.get(
route('activity-logs.index'), route('activity-logs.index'),
{ ...filters, per_page: value }, { ...filters, per_page: value, page: 1 },
{ preserveState: false, replace: true, preserveScroll: true } { preserveState: false, replace: true, preserveScroll: true }
); );
}; };

View File

@@ -4,19 +4,8 @@ import { Shield, ArrowLeft, Check } from 'lucide-react';
import { Button } from '@/Components/ui/button'; import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input'; import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label'; import { Label } from '@/Components/ui/label';
import { Checkbox } from '@/Components/ui/checkbox';
import { FormEvent } from 'react'; import { FormEvent } from 'react';
import PermissionSelector, { GroupedPermission } from './PermissionSelector';
interface Permission {
id: number;
name: string;
}
interface GroupedPermission {
key: string;
name: string;
permissions: Permission[];
}
interface Props { interface Props {
groupedPermissions: GroupedPermission[]; groupedPermissions: GroupedPermission[];
@@ -34,56 +23,6 @@ export default function RoleCreate({ groupedPermissions }: Props) {
post(route('roles.store')); post(route('roles.store'));
}; };
const togglePermission = (name: string) => {
if (data.permissions.includes(name)) {
setData('permissions', data.permissions.filter(p => p !== name));
} else {
setData('permissions', [...data.permissions, name]);
}
};
const toggleGroup = (groupPermissions: Permission[]) => {
const groupNames = groupPermissions.map(p => p.name);
const allSelected = groupNames.every(name => data.permissions.includes(name));
if (allSelected) {
// Unselect all
setData('permissions', data.permissions.filter(p => !groupNames.includes(p)));
} else {
// Select all
const newPermissions = [...data.permissions];
groupNames.forEach(name => {
if (!newPermissions.includes(name)) newPermissions.push(name);
});
setData('permissions', newPermissions);
}
};
// 翻譯權限後綴
const translateAction = (permissionName: string) => {
const parts = permissionName.split('.');
if (parts.length < 2) return permissionName;
const action = parts[1];
const map: Record<string, string> = {
'view': '檢視',
'create': '新增',
'edit': '編輯',
'delete': '刪除',
'publish': '發布',
'adjust': '調整',
'transfer': '調撥',
'safety_stock': '安全庫存設定',
'export': '匯出',
'complete': '完成',
'view_cost': '檢視成本',
'view_logs': '檢視日誌',
'activate': '啟用/停用',
};
return map[action] || action;
};
return ( return (
<AuthenticatedLayout <AuthenticatedLayout
breadcrumbs={[ breadcrumbs={[
@@ -171,52 +110,11 @@ export default function RoleCreate({ groupedPermissions }: Props) {
{/* Permissions Matrix */} {/* Permissions Matrix */}
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-bold text-grey-0"></h2> <h2 className="text-lg font-bold text-grey-0"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <PermissionSelector
{groupedPermissions.map((group) => { groupedPermissions={groupedPermissions}
const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name)); selectedPermissions={data.permissions}
onChange={(permissions) => setData('permissions', permissions)}
return (
<div key={group.key} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<span className="font-medium text-gray-700">{group.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => toggleGroup(group.permissions)}
className="text-xs h-7 text-primary-main hover:text-primary-main hover:bg-primary-main/10"
>
{allGroupSelected ? '取消全選' : '全選'}
</Button>
</div>
<div className="p-4 flex-1">
<div className="space-y-3">
{group.permissions.map((permission) => (
<div key={permission.id} className="flex items-start space-x-3">
<Checkbox
id={permission.name}
checked={data.permissions.includes(permission.name)}
onCheckedChange={() => togglePermission(permission.name)}
/> />
<div className="grid gap-1.5 leading-none">
<label
htmlFor={permission.name}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{translateAction(permission.name)}
</label>
<p className="text-[10px] text-gray-400 font-mono">
{permission.name}
</p>
</div>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -4,19 +4,8 @@ import { Shield, ArrowLeft, Check, AlertCircle } from 'lucide-react';
import { Button } from '@/Components/ui/button'; import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input'; import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label'; import { Label } from '@/Components/ui/label';
import { Checkbox } from '@/Components/ui/checkbox';
import { FormEvent } from 'react'; import { FormEvent } from 'react';
import PermissionSelector, { GroupedPermission } from './PermissionSelector';
interface Permission {
id: number;
name: string;
}
interface GroupedPermission {
key: string;
name: string;
permissions: Permission[];
}
interface Role { interface Role {
id: number; id: number;
@@ -42,71 +31,6 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
put(route('roles.update', role.id)); put(route('roles.update', role.id));
}; };
const togglePermission = (name: string) => {
if (data.permissions.includes(name)) {
setData('permissions', data.permissions.filter(p => p !== name));
} else {
setData('permissions', [...data.permissions, name]);
}
};
const toggleGroup = (groupPermissions: Permission[]) => {
const groupNames = groupPermissions.map(p => p.name);
const allSelected = groupNames.every(name => data.permissions.includes(name));
if (allSelected) {
// Unselect all
setData('permissions', data.permissions.filter(p => !groupNames.includes(p)));
} else {
// Select all
const newPermissions = [...data.permissions];
groupNames.forEach(name => {
if (!newPermissions.includes(name)) newPermissions.push(name);
});
setData('permissions', newPermissions);
}
};
const translateAction = (permissionName: string) => {
const parts = permissionName.split('.');
if (parts.length < 2) return permissionName;
const action = parts[parts.length - 1];
const map: Record<string, string> = {
'view': '檢視',
'create': '新增',
'edit': '編輯',
'delete': '刪除',
'publish': '發佈',
'adjust': '調整',
'transfer': '調撥',
'count': '盤點',
// 'inventory_count': '盤點', // Hide prefix
// 'inventory_adjust': '盤調', // Hide prefix
// 'inventory_transfer': '調撥', // Hide prefix
'safety_stock': '安全庫存設定',
'export': '匯出',
'complete': '完成',
'view_cost': '檢視成本',
'view_logs': '檢視日誌',
'activate': '啟用/停用',
};
const actionText = map[action] || action;
// 處理多段式權限 (例如 inventory_count.view)
if (parts.length >= 2) {
const middleKey = parts[parts.length - 2];
// 如果中間那段有翻譯且不等於動作本身,則顯示為 "標籤: 動作"
if (map[middleKey] && middleKey !== action) {
return `${map[middleKey]}: ${actionText}`;
}
}
return actionText;
};
return ( return (
<AuthenticatedLayout <AuthenticatedLayout
breadcrumbs={[ breadcrumbs={[
@@ -201,52 +125,11 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
{/* Permissions Matrix */} {/* Permissions Matrix */}
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-bold text-grey-0"></h2> <h2 className="text-lg font-bold text-grey-0"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <PermissionSelector
{groupedPermissions.map((group) => { groupedPermissions={groupedPermissions}
const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name)); selectedPermissions={data.permissions}
onChange={(permissions) => setData('permissions', permissions)}
return (
<div key={group.key} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<span className="font-medium text-gray-700">{group.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => toggleGroup(group.permissions)}
className="text-xs h-7 text-primary-main hover:text-primary-main hover:bg-primary-main/10"
>
{allGroupSelected ? '取消全選' : '全選'}
</Button>
</div>
<div className="p-4 flex-1">
<div className="space-y-3">
{group.permissions.map((permission) => (
<div key={permission.id} className="flex items-start space-x-3">
<Checkbox
id={permission.name}
checked={data.permissions.includes(permission.name)}
onCheckedChange={() => togglePermission(permission.name)}
/> />
<div className="grid gap-1.5 leading-none">
<label
htmlFor={permission.name}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{translateAction(permission.name)}
</label>
<p className="text-[10px] text-gray-400 font-mono">
{permission.name}
</p>
</div>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -0,0 +1,277 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/Components/ui/accordion";
import { Button } from "@/Components/ui/button";
import { Checkbox } from "@/Components/ui/checkbox";
import { Input } from "@/Components/ui/input";
import { cn } from "@/lib/utils";
import { useState, useEffect, useMemo } from "react";
import { ChevronsDown, ChevronsUp } from "lucide-react";
export interface Permission {
id: number;
name: string;
}
export interface GroupedPermission {
key: string;
name: string;
permissions: Permission[];
}
interface PermissionSelectorProps {
groupedPermissions: GroupedPermission[];
selectedPermissions: string[];
onChange: (permissions: string[]) => void;
}
export default function PermissionSelector({ groupedPermissions, selectedPermissions, onChange }: PermissionSelectorProps) {
// 翻譯權限後綴
const translateAction = (permissionName: string) => {
const parts = permissionName.split('.');
if (parts.length < 2) return permissionName;
const action = parts[parts.length - 1];
const map: Record<string, string> = {
'view': '檢視',
'create': '新增',
'edit': '編輯',
'delete': '刪除',
'adjust': '調整',
'transfer': '調撥',
'count': '盤點',
'safety_stock': '安全庫存設定',
'export': '匯出',
'complete': '完成',
'view_cost': '檢視成本',
'view_logs': '檢視日誌',
'activate': '啟用/停用',
};
const actionText = map[action] || action;
// 處理多段式權限 (例如 inventory_count.view)
if (parts.length >= 2) {
const middleKey = parts[parts.length - 2];
// 如果中間那段有翻譯且不等於動作本身,則顯示為 "標籤: 動作"
if (map[middleKey] && middleKey !== action) {
return `${map[middleKey]}: ${actionText}`;
}
}
return actionText;
};
const togglePermission = (name: string) => {
if (selectedPermissions.includes(name)) {
onChange(selectedPermissions.filter(p => p !== name));
} else {
onChange([...selectedPermissions, name]);
}
};
const toggleGroup = (groupPermissions: Permission[]) => {
const groupNames = groupPermissions.map(p => p.name);
const allSelected = groupNames.every(name => selectedPermissions.includes(name));
if (allSelected) {
// Unselect all
onChange(selectedPermissions.filter(p => !groupNames.includes(p)));
} else {
// Select all
const newPermissions = [...selectedPermissions];
groupNames.forEach(name => {
if (!newPermissions.includes(name)) newPermissions.push(name);
});
onChange(newPermissions);
}
};
// State for controlling accordion items
const [openItems, setOpenItems] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
// Memoize filtered groups to prevent infinite loops in useEffect
const filteredGroups = useMemo(() => {
return groupedPermissions.map(group => {
// If search is empty, return group as is
if (!searchQuery.trim()) return group;
// Check if group name matches
const groupNameMatch = group.name.toLowerCase().includes(searchQuery.toLowerCase());
// Filter permissions that match
const matchingPermissions = group.permissions.filter(p => {
const translatedName = translateAction(p.name);
return translatedName.includes(searchQuery) ||
p.name.toLowerCase().includes(searchQuery.toLowerCase());
});
// If group name matches, show all permissions. Otherwise show only matching permissions.
if (groupNameMatch) {
return group;
}
return {
...group,
permissions: matchingPermissions
};
}).filter(group => group.permissions.length > 0);
}, [groupedPermissions, searchQuery]);
const currentDisplayKeys = useMemo(() => filteredGroups.map(g => g.key), [filteredGroups]);
const onExpandAll = () => setOpenItems(currentDisplayKeys);
const onCollapseAll = () => setOpenItems([]);
// Auto-expand groups when searching
useEffect(() => {
if (searchQuery.trim()) {
const filteredKeys = filteredGroups.map(g => g.key);
setOpenItems(prev => {
const next = new Set([...prev, ...filteredKeys]);
return Array.from(next);
});
}
// removing the 'else' block which forced collapse on empty search
// We let the user manually collapse if they want, or we could reset only when search is cleared explicitly?
// User behavior: if I finish searching, I might want to see my previous state, but "Expand All" failing was the main issue.
// The issue was the effect running on every render resetting state.
}, [searchQuery]); // Only run when query changes. We actually depend on filteredGroups result but only when query changes matters most for "auto" trigger.
// Actually, correctly: if filteredGroups changes due to search change, we expand.
// Better interaction: When search query *changes* and is not empty, we expand matches.
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-2">
<div className="relative flex-1 max-w-sm">
<Input
placeholder="搜尋權限名稱..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
<div className="absolute left-2.5 top-2.5 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
</div>
</div>
<div className="flex items-center justify-between sm:justify-end gap-4 w-full sm:w-auto">
<div className="text-sm text-gray-500 whitespace-nowrap">
{selectedPermissions.length}
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onExpandAll}
className="h-8 text-xs text-gray-600 hover:text-primary-main gap-1.5"
>
<ChevronsDown className="h-3.5 w-3.5" />
</Button>
<div className="h-4 w-px bg-gray-200" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCollapseAll}
className="h-8 text-xs text-gray-600 hover:text-primary-main gap-1.5"
>
<ChevronsUp className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<Accordion
type="multiple"
value={openItems}
onValueChange={setOpenItems}
className="w-full space-y-2"
>
{filteredGroups.length === 0 ? (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
{searchQuery}
</div>
) : (
filteredGroups.map((group) => {
const selectedCount = group.permissions.filter(p => selectedPermissions.includes(p.name)).length;
const totalCount = group.permissions.length;
const isAllSelected = selectedCount === totalCount;
return (
<AccordionItem
key={group.key}
value={group.key}
className="bg-white border rounded-lg px-2 data-[state=open]:bg-gray-50/50 last:border-b"
>
<div className="flex items-center w-full">
{/* Group Selection Checkbox - Moved outside trigger to avoid bubbling issues, positioned left */}
<div className="flex items-center pl-2 pr-1">
<Checkbox
id={`group-select-${group.key}`}
checked={isAllSelected}
onCheckedChange={() => {
// Stop propagation to prevent accordion from toggling
// This is implicitly handled by the checkbox being a sibling,
// but if it were a child of AccordionTrigger, stopPropagation would be needed.
// For clarity, we can add it here if needed, but the current structure makes it unnecessary.
toggleGroup(group.permissions);
}}
className="data-[state=checked]:bg-primary-main"
/>
</div>
<AccordionTrigger className="px-2 hover:no-underline hover:bg-gray-50 rounded-lg group flex-1 py-3">
<div className="flex items-center gap-3">
<span className={cn(
"font-medium",
selectedCount > 0 ? "text-primary-main" : "text-gray-700"
)}>
{group.name}
</span>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full font-mono">
{selectedCount} / {totalCount}
</span>
</div>
</AccordionTrigger>
</div>
<AccordionContent className="px-2 pb-4">
<div className="pl-10 space-y-3 pt-1">
{/* Permissions Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-y-3 gap-x-4">
{group.permissions.map((permission) => (
<div key={permission.id} className="flex items-start space-x-3">
<Checkbox
id={permission.name}
checked={selectedPermissions.includes(permission.name)}
onCheckedChange={() => togglePermission(permission.name)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={permission.name}
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer text-gray-700 hover:text-primary-main transition-colors"
>
{translateAction(permission.name)}
</label>
<p className="text-[10px] text-gray-400 font-mono">
{permission.name}
</p>
</div>
</div>
))}
</div>
</div>
</AccordionContent>
</AccordionItem>
);
})
)}
</Accordion>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { Head, Link } from "@inertiajs/react";
import { Hammer, Home, ArrowLeft } from "lucide-react";
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
interface Props {
featureName?: string;
}
export default function UnderConstruction({ featureName = "此功能" }: Props) {
return (
<AuthenticatedLayout breadcrumbs={[
{ label: '系統訊息', href: '#' },
{ label: '功能製作中', isPage: true }
] as any}>
<Head title="功能製作中" />
<div className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center">
<div className="relative mb-8">
<div className="absolute inset-0 bg-primary/10 rounded-full animate-ping opacity-25"></div>
<div className="relative bg-white p-8 rounded-full shadow-xl border-4 border-primary/20">
<Hammer className="h-20 w-20 text-primary-main animate-bounce" />
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">
{featureName}
</h1>
<p className="text-gray-500 max-w-md mb-10 text-lg leading-relaxed">
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button
variant="outline"
size="lg"
className="button-outlined-primary gap-2 min-w-[150px]"
onClick={() => window.history.back()}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<Link href={route('dashboard')}>
<Button
variant="default"
size="lg"
className="button-filled-primary gap-2 min-w-[150px]"
>
<Home className="h-5 w-5" />
</Button>
</Link>
</div>
<div className="mt-16 text-sm text-gray-400 font-mono">
Coming Soon | Star ERP Design System
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -1,36 +0,0 @@
import { Link } from "@inertiajs/react";
import { ShieldAlert, Home } from "lucide-react";
export default function Error403() {
return (
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center p-6">
<div className="max-w-md w-full text-center">
{/* 圖示 */}
<div className="mb-6 flex justify-center">
<div className="w-24 h-24 bg-red-100 rounded-full flex items-center justify-center">
<ShieldAlert className="w-12 h-12 text-red-500" />
</div>
</div>
{/* 標題 */}
<h1 className="text-3xl font-bold text-slate-900 mb-2">
</h1>
{/* 說明 */}
<p className="text-slate-600 mb-8">
</p>
{/* 返回按鈕 */}
<Link
href="/"
className="inline-flex items-center gap-2 px-6 py-3 bg-primary-main text-white rounded-lg hover:bg-primary-dark transition-colors"
>
<Home className="w-5 h-5" />
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { Head, Link } from "@inertiajs/react";
import { ShieldAlert, FileQuestion, ServerCrash, HardHat, Home, ArrowLeft } from "lucide-react";
import { Button } from "@/Components/ui/button";
interface Props {
status: number;
message?: string;
}
export default function ErrorPage({ status, message }: Props) {
const errorDetails: Record<number, { title: string; description: string; icon: any; color: string }> = {
403: {
title: "無此權限 (403)",
description: "抱歉,您沒有權限存取此頁面。如果您認為這是個錯誤,請聯繫系統管理員。",
icon: ShieldAlert,
color: "text-yellow-500 bg-yellow-100 border-yellow-200",
},
404: {
title: "頁面未找到 (404)",
description: "抱歉,我們找不到您要訪問的頁面。它可能已被移除、更改名稱或暫時不可用。",
icon: FileQuestion,
color: "text-blue-500 bg-blue-100 border-blue-200",
},
500: {
title: "伺服器錯誤 (500)",
description: "抱歉,伺服器發生了內部錯誤。我們的技術團隊已經收到通知,正在努力修復中。",
icon: ServerCrash,
color: "text-red-500 bg-red-100 border-red-200",
},
503: {
title: "服務維護中 (503)",
description: "抱歉,系統目前正在進行維護。請稍後再試。",
icon: HardHat,
color: "text-orange-500 bg-orange-100 border-orange-200",
},
};
const defaultError = {
title: "發生錯誤",
description: message || "發生了未知的錯誤。",
icon: ShieldAlert,
color: "text-gray-500 bg-gray-100 border-gray-200",
};
const details = errorDetails[status] || defaultError;
const Icon = details.icon;
return (
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center p-4">
<Head title={details.title} />
<div className="max-w-md w-full text-center slide-in-bottom"> {/* slide-in-bottom need to be defined in global css or just use simple animation */}
{/* Icon Circle */}
<div className="mb-8 flex justify-center relative">
<div className={`absolute inset-0 rounded-full animate-ping opacity-20 ${details.color.split(' ')[1]}`}></div>
<div className={`relative w-24 h-24 rounded-full flex items-center justify-center border-4 shadow-xl ${details.color}`}>
<Icon className="w-12 h-12" />
</div>
</div>
{/* Text Content */}
<h1 className="text-3xl font-bold text-slate-900 mb-3 tracking-tight">
{details.title}
</h1>
<p className="text-slate-500 mb-10 text-lg leading-relaxed">
{details.description}
</p>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
variant="outline"
size="lg"
className="gap-2 min-w-[140px] border-slate-300 hover:bg-slate-100 text-slate-700"
onClick={() => window.history.back()}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<Link href={route('dashboard')}>
<Button
variant="default"
size="lg"
className="gap-2 min-w-[140px] shadow-lg shadow-primary/20"
>
<Home className="h-5 w-5" />
</Button>
</Link>
</div>
<div className="mt-16 text-sm text-slate-400 font-mono">
Error Code: {status} | Star ERP System
</div>
</div>
</div>
);
}

View File

@@ -48,8 +48,10 @@ interface AdjItem {
qty_before: number | string; qty_before: number | string;
adjust_qty: number | string; adjust_qty: number | string;
notes: string; notes: string;
expiry_date?: string | null;
} }
interface AdjDoc { interface AdjDoc {
id: string; id: string;
doc_no: string; doc_no: string;
@@ -155,6 +157,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
qty_before: inv.quantity || 0, qty_before: inv.quantity || 0,
adjust_qty: 0, adjust_qty: 0,
notes: '', notes: '',
expiry_date: inv.expiry_date,
}); });
addedCount++; addedCount++;
} }
@@ -253,7 +256,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
<> <>
<span className="mx-1">|</span> <span className="mx-1">|</span>
<Link <Link
href={route('inventory.count.show', [doc.count_doc_id])} href={route('inventory.count.show', [doc.count_doc_id]) + `?from=adjust&adjust_id=${doc.id}`}
className="flex items-center gap-1 text-primary-main hover:underline" className="flex items-center gap-1 text-primary-main hover:underline"
> >
: {doc.count_doc_no} : {doc.count_doc_no}
@@ -409,9 +412,10 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
onCheckedChange={() => toggleSelectAll()} onCheckedChange={() => toggleSelectAll()}
/> />
</TableHead> </TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead> <TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead> <TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="text-right font-medium text-grey-600 pr-6"></TableHead> <TableHead className="text-right font-medium text-grey-600 pr-6"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -447,9 +451,10 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
onCheckedChange={() => toggleSelect(key)} onCheckedChange={() => toggleSelect(key)}
/> />
</TableCell> </TableCell>
<TableCell className="font-mono text-sm text-grey-1">{inv.product_code}</TableCell>
<TableCell className="font-semibold text-grey-0">{inv.product_name}</TableCell> <TableCell className="font-semibold text-grey-0">{inv.product_name}</TableCell>
<TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell> <TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell>
<TableCell className="text-sm font-mono text-grey-2">{inv.expiry_date || '-'}</TableCell>
<TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell> <TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
</TableRow> </TableRow>
); );
@@ -532,7 +537,14 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span> <span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-grey-600 font-mono text-sm">{item.batch_number || '-'}</TableCell> <TableCell className="text-grey-600 font-mono text-sm">
<div>{item.batch_number || '-'}</div>
{item.expiry_date && (
<div className="text-xs text-gray-400 mt-1">
: {item.expiry_date}
</div>
)}
</TableCell>
<TableCell className="text-center text-grey-500">{item.unit}</TableCell> <TableCell className="text-center text-grey-500">{item.unit}</TableCell>
<TableCell className="text-right font-medium text-grey-400"> <TableCell className="text-right font-medium text-grey-400">
{item.qty_before} {item.qty_before}
@@ -542,7 +554,8 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
<div className="flex justify-end pr-2"> <div className="flex justify-end pr-2">
<Input <Input
type="number" type="number"
className="text-right h-9 w-32 font-medium" step="any"
className="h-9 w-32 font-medium text-right"
value={item.adjust_qty} value={item.adjust_qty}
onChange={e => updateItem(index, 'adjust_qty', e.target.value)} onChange={e => updateItem(index, 'adjust_qty', e.target.value)}
/> />
@@ -568,9 +581,9 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
{!isReadOnly && !doc.count_doc_id && ( {!isReadOnly && !doc.count_doc_id && (
<TableCell className="text-center"> <TableCell className="text-center">
<Button <Button
variant="ghost" variant="outline"
size="sm" size="icon"
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 p-0" className="button-outlined-error h-8 w-8"
onClick={() => removeItem(index)} onClick={() => removeItem(index)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />

View File

@@ -1,5 +1,5 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, useForm, router, usePage } from '@inertiajs/react'; import { Head, Link, useForm, router } from '@inertiajs/react';
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { usePermission } from '@/hooks/usePermission'; import { usePermission } from '@/hooks/usePermission';
import { debounce } from "lodash"; import { debounce } from "lodash";
@@ -47,7 +47,7 @@ import {
import Pagination from '@/Components/shared/Pagination'; import Pagination from '@/Components/shared/Pagination';
import { Can } from '@/Components/Permission/Can'; import { Can } from '@/Components/Permission/Can';
export default function Index({ auth, docs, warehouses, filters }: any) { export default function Index({ docs, warehouses, filters }: any) {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const { data, setData, post, processing, reset, errors, delete: destroy } = useForm({ const { data, setData, post, processing, reset, errors, delete: destroy } = useForm({
@@ -112,7 +112,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
); );
}; };
const handleCreate = (e) => { const handleCreate = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
post(route('inventory.count.store'), { post(route('inventory.count.store'), {
onSuccess: () => { onSuccess: () => {
@@ -135,14 +135,16 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
} }
}; };
const getStatusBadge = (status) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
case 'draft': case 'draft':
return <Badge variant="secondary">稿</Badge>; return <Badge variant="secondary">稿</Badge>;
case 'counting': case 'counting':
return <Badge className="bg-blue-500 hover:bg-blue-600"></Badge>; return <Badge className="bg-blue-500 hover:bg-blue-600"></Badge>;
case 'completed': case 'completed':
return <Badge className="bg-green-500 hover:bg-green-600"></Badge>; return <Badge className="bg-green-500 hover:bg-green-600"></Badge>;
case 'no_adjust':
return <Badge className="bg-green-600 hover:bg-green-700"> (調)</Badge>;
case 'adjusted': case 'adjusted':
return <Badge className="bg-purple-500 hover:bg-purple-600">調</Badge>; return <Badge className="bg-purple-500 hover:bg-purple-600">調</Badge>;
case 'cancelled': case 'cancelled':
@@ -155,7 +157,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
return ( return (
<AuthenticatedLayout <AuthenticatedLayout
breadcrumbs={[ breadcrumbs={[
{ label: '商品與庫存管理', href: '#' }, { label: '商品與庫存管理', href: '#' },
{ label: '庫存盤點', href: route('inventory.count.index'), isPage: true }, { label: '庫存盤點', href: route('inventory.count.index'), isPage: true },
]} ]}
> >
@@ -287,7 +289,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
docs.data.map((doc, index) => ( docs.data.map((doc: any, index: number) => (
<TableRow key={doc.id}> <TableRow key={doc.id}>
<TableCell className="text-gray-500 font-medium text-center"> <TableCell className="text-gray-500 font-medium text-center">
{(docs.current_page - 1) * docs.per_page + index + 1} {(docs.current_page - 1) * docs.per_page + index + 1}
@@ -307,7 +309,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{/* Action Button Logic: Prefer Edit if allowed and status is active, otherwise fallback to View if allowed */} {/* Action Button Logic: Prefer Edit if allowed and status is active, otherwise fallback to View if allowed */}
{(() => { {(() => {
const isEditable = !['completed', 'adjusted'].includes(doc.status); const isEditable = !['completed', 'no_adjust', 'adjusted'].includes(doc.status);
const canEdit = can('inventory_count.edit'); const canEdit = can('inventory_count.edit');
const canView = can('inventory_count.view'); const canView = can('inventory_count.view');
@@ -343,7 +345,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
return null; return null;
})()} })()}
{!['completed', 'adjusted'].includes(doc.status) && ( {!['completed', 'no_adjust', 'adjusted'].includes(doc.status) && (
<Can permission="inventory_count.delete"> <Can permission="inventory_count.delete">
<Button <Button
variant="outline" variant="outline"

View File

@@ -12,7 +12,7 @@ import {
import { Button } from '@/Components/ui/button'; import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input'; import { Input } from '@/Components/ui/input';
import { Badge } from '@/Components/ui/badge'; import { Badge } from '@/Components/ui/badge';
import { Save, CheckCircle, Printer, Trash2, ClipboardCheck, ArrowLeft, RotateCcw } from 'lucide-react'; // Added ArrowLeft import { Save, Printer, Trash2, ClipboardCheck, ArrowLeft, RotateCcw } from 'lucide-react'; // Added ArrowLeft
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -26,7 +26,19 @@ import {
} from "@/Components/ui/alert-dialog" } from "@/Components/ui/alert-dialog"
import { Can } from '@/Components/Permission/Can'; import { Can } from '@/Components/Permission/Can';
export default function Show({ doc }: any) { export default function Show({ doc }: any) {
// Get query parameters for dynamic back button
const urlParams = new URLSearchParams(window.location.search);
const fromSource = urlParams.get('from');
const adjustId = urlParams.get('adjust_id');
const backUrl = fromSource === 'adjust' && adjustId
? route('inventory.adjust.show', [adjustId])
: route('inventory.count.index');
const backLabel = fromSource === 'adjust' ? '返回盤調單' : '返回盤點單列表';
// Transform items to form data structure // Transform items to form data structure
const { data, setData, put, delete: destroy, processing, transform } = useForm({ const { data, setData, put, delete: destroy, processing, transform } = useForm({
items: doc.items.map((item: any) => ({ items: doc.items.map((item: any) => ({
@@ -63,7 +75,7 @@ export default function Show({ doc }: any) {
} }
const { can } = usePermission(); const { can } = usePermission();
const isCompleted = ['completed', 'adjusted'].includes(doc.status); const isCompleted = ['completed', 'no_adjust', 'adjusted'].includes(doc.status);
const canEdit = can('inventory_count.edit'); const canEdit = can('inventory_count.edit');
const isReadOnly = isCompleted || !canEdit; const isReadOnly = isCompleted || !canEdit;
@@ -76,21 +88,28 @@ export default function Show({ doc }: any) {
<AuthenticatedLayout <AuthenticatedLayout
breadcrumbs={[ breadcrumbs={[
{ label: '商品與庫存管理', href: '#' }, { label: '商品與庫存管理', href: '#' },
{ label: '庫存盤點', href: route('inventory.count.index') }, {
label: fromSource === 'adjust' ? '庫存盤調' : '庫存盤點',
href: fromSource === 'adjust' ? route('inventory.adjust.index') : route('inventory.count.index')
},
fromSource === 'adjust' && adjustId ? {
label: `盤調單詳情`,
href: route('inventory.adjust.show', [adjustId])
} : null,
{ label: `盤點單: ${doc.doc_no}`, href: route('inventory.count.show', [doc.id]), isPage: true }, { label: `盤點單: ${doc.doc_no}`, href: route('inventory.count.show', [doc.id]), isPage: true },
]} ].filter(Boolean) as any}
> >
<Head title={`盤點單 ${doc.doc_no}`} /> <Head title={`盤點單 ${doc.doc_no}`} />
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500 space-y-6"> <div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500 space-y-6">
<div> <div>
<Link href={route('inventory.count.index')}> <Link href={backUrl}>
<Button <Button
variant="outline" variant="outline"
className="gap-2 button-outlined-primary mb-6" className="gap-2 button-outlined-primary mb-6"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
{backLabel}
</Button> </Button>
</Link> </Link>
@@ -102,7 +121,10 @@ export default function Show({ doc }: any) {
: {doc.doc_no} : {doc.doc_no}
</h1> </h1>
{doc.status === 'completed' && ( {doc.status === 'completed' && (
<Badge className="bg-green-500 hover:bg-green-600"></Badge> <Badge className="bg-green-500 hover:bg-green-600"></Badge>
)}
{doc.status === 'no_adjust' && (
<Badge className="bg-green-600 hover:bg-green-700"> (調)</Badge>
)} )}
{doc.status === 'adjusted' && ( {doc.status === 'adjusted' && (
<Badge className="bg-purple-500 hover:bg-purple-600">調</Badge> <Badge className="bg-purple-500 hover:bg-purple-600">調</Badge>
@@ -127,7 +149,7 @@ export default function Show({ doc }: any) {
</Button> </Button>
{doc.status === 'completed' && ( {['completed', 'no_adjust'].includes(doc.status) && (
<Can permission="inventory.adjust"> <Can permission="inventory.adjust">
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
@@ -138,19 +160,19 @@ export default function Show({ doc }: any) {
disabled={processing} disabled={processing}
> >
<RotateCcw className="w-4 h-4 mr-2" /> <RotateCcw className="w-4 h-4 mr-2" />
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle> <AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel> <AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleReopen} className="bg-red-600 hover:bg-red-700"></AlertDialogAction> <AlertDialogAction onClick={handleReopen} className="bg-red-600 hover:bg-red-700"></AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
@@ -184,23 +206,13 @@ export default function Show({ doc }: any) {
<Can permission="inventory_count.edit"> <Can permission="inventory_count.edit">
<Button <Button
variant="outline"
size="sm" size="sm"
className="button-outlined-primary" className="button-filled-primary"
onClick={() => handleSubmit('save')} onClick={() => handleSubmit('save')}
disabled={processing} disabled={processing}
> >
<Save className="w-4 h-4 mr-2" /> <Save className="w-4 h-4 mr-2" />
</Button>
<Button
size="sm"
className="button-filled-primary"
onClick={() => handleSubmit('complete')}
disabled={processing}
>
<CheckCircle className="w-4 h-4 mr-2" />
</Button> </Button>
</Can> </Can>
</div> </div>
@@ -264,20 +276,27 @@ export default function Show({ doc }: any) {
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span> <span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell> <TableCell className="text-sm font-mono">
<TableCell className="text-right font-medium">{item.system_qty.toFixed(0)}</TableCell> <div>{item.batch_number || '-'}</div>
{item.expiry_date && (
<div className="text-xs text-gray-400 mt-1">
: {item.expiry_date}
</div>
)}
</TableCell>
<TableCell className="text-right font-medium">{Number(item.system_qty)}</TableCell>
<TableCell className="text-right px-1 py-3"> <TableCell className="text-right px-1 py-3">
{isReadOnly ? ( {isReadOnly ? (
<span className="font-semibold mr-2">{item.counted_qty}</span> <span className="font-semibold mr-2">{item.counted_qty}</span>
) : ( ) : (
<Input <Input
type="number" type="number"
step="1" step="any"
value={formItem.counted_qty ?? ''} value={formItem.counted_qty ?? ''}
onChange={(e) => updateItem(index, 'counted_qty', e.target.value)} onChange={(e) => updateItem(index, 'counted_qty', e.target.value)}
onWheel={(e: any) => e.target.blur()} onWheel={(e: any) => e.target.blur()}
disabled={processing} disabled={processing}
className="h-9 text-right font-medium focus:ring-primary-main" className="h-9 font-medium focus:ring-primary-main text-right"
placeholder="盤點..." placeholder="盤點..."
/> />
)} )}
@@ -290,7 +309,7 @@ export default function Show({ doc }: any) {
: 'text-red-600' : 'text-red-600'
}`}> }`}>
{formItem.counted_qty !== '' && formItem.counted_qty !== null {formItem.counted_qty !== '' && formItem.counted_qty !== null
? diff.toFixed(0) ? Number(diff.toFixed(2))
: '-'} : '-'}
</span> </span>
</TableCell> </TableCell>
@@ -318,6 +337,7 @@ export default function Show({ doc }: any) {
</div> </div>
</AuthenticatedLayout > </AuthenticatedLayout >

View File

@@ -38,13 +38,7 @@ import { STATUS_CONFIG } from '@/constants/purchase-order';
interface BatchItem {
inventoryId: string;
batchNumber: string;
originCountry: string;
expiryDate: string | null;
quantity: number;
}
// 待進貨採購單 Item 介面 // 待進貨採購單 Item 介面
interface PendingPOItem { interface PendingPOItem {
@@ -207,13 +201,12 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
}; };
// Batch management // Batch management
const [batchesCache, setBatchesCache] = useState<Record<string, BatchItem[]>>({});
const [nextSequences, setNextSequences] = useState<Record<string, number>>({}); const [nextSequences, setNextSequences] = useState<Record<string, number>>({});
// Fetch batches and sequence for a product // Fetch batches and sequence for a product
const fetchProductBatches = async (productId: number, country: string = 'TW', dateStr: string = '') => { const fetchProductBatches = async (productId: number, country: string = 'TW', dateStr: string = '') => {
if (!data.warehouse_id) return; if (!data.warehouse_id) return;
const cacheKey = `${productId}-${data.warehouse_id}`; // const cacheKey = `${productId}-${data.warehouse_id}`; // Unused
try { try {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
@@ -233,13 +226,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
); );
if (response.data) { if (response.data) {
// Update existing batches list // Remove unused batch cache update
if (response.data.batches) {
setBatchesCache(prev => ({
...prev,
[cacheKey]: response.data.batches
}));
}
// Update next sequence for new batch generation // Update next sequence for new batch generation
if (response.data.nextSequence !== undefined) { if (response.data.nextSequence !== undefined) {
@@ -314,7 +301,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<Link href={route('goods-receipts.index')}> <Link href={route('goods-receipts.index')}>
<Button variant="outline" className="gap-2 mb-4 w-fit"> <Button variant="outline" type="button" className="gap-2 mb-4 w-fit button-outlined-primary">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
@@ -645,11 +632,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
<TableCell> <TableCell>
<Input <Input
type="number" type="number"
step="1" step="any"
min="0" min="0"
value={item.quantity_received} value={item.quantity_received}
onChange={(e) => updateItem(index, 'quantity_received', e.target.value)} onChange={(e) => updateItem(index, 'quantity_received', e.target.value)}
className={`w-full ${(errors as any)[errorKey] ? 'border-red-500' : ''}`} className={`w-full text-right ${errors && (errors as any)[errorKey] ? 'border-red-500' : ''}`}
/> />
{(errors as any)[errorKey] && ( {(errors as any)[errorKey] && (
<p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p> <p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p>

View File

@@ -173,7 +173,7 @@ export default function Index({ warehouses, orders, filters }: any) {
return ( return (
<AuthenticatedLayout <AuthenticatedLayout
breadcrumbs={[ breadcrumbs={[
{ label: '商品與庫存管理', href: '#' }, { label: '商品與庫存管理', href: '#' },
{ label: '庫存調撥', href: route('inventory.transfer.index'), isPage: true }, { label: '庫存調撥', href: route('inventory.transfer.index'), isPage: true },
]} ]}
> >

View File

@@ -116,6 +116,7 @@ export default function Show({ order }: any) {
product_name: inv.product_name, product_name: inv.product_name,
product_code: inv.product_code, product_code: inv.product_code,
batch_number: inv.batch_number, batch_number: inv.batch_number,
expiry_date: inv.expiry_date,
unit: inv.unit_name, unit: inv.unit_name,
quantity: 1, // Default 1 quantity: 1, // Default 1
max_quantity: inv.quantity, // Max available max_quantity: inv.quantity, // Max available
@@ -154,7 +155,7 @@ export default function Show({ order }: any) {
items: items, items: items,
remarks: remarks, remarks: remarks,
}, { }, {
onSuccess: () => toast.success("儲存成功"), onSuccess: () => { },
onError: () => toast.error("儲存失敗,請檢查輸入"), onError: () => toast.error("儲存失敗,請檢查輸入"),
}); });
} finally { } finally {
@@ -168,7 +169,6 @@ export default function Show({ order }: any) {
}, { }, {
onSuccess: () => { onSuccess: () => {
setIsPostDialogOpen(false); setIsPostDialogOpen(false);
toast.success("過帳成功");
} }
}); });
}; };
@@ -177,7 +177,6 @@ export default function Show({ order }: any) {
router.delete(route('inventory.transfer.destroy', [order.id]), { router.delete(route('inventory.transfer.destroy', [order.id]), {
onSuccess: () => { onSuccess: () => {
setDeleteId(null); setDeleteId(null);
toast.success("已成功刪除");
} }
}); });
}; };
@@ -373,9 +372,10 @@ export default function Show({ order }: any) {
onCheckedChange={() => toggleSelectAll()} onCheckedChange={() => toggleSelectAll()}
/> />
</TableHead> </TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead> <TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead> <TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="text-right font-medium text-grey-600 pr-6"></TableHead> <TableHead className="text-right font-medium text-grey-600 pr-6"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -411,9 +411,10 @@ export default function Show({ order }: any) {
onCheckedChange={() => toggleSelect(key)} onCheckedChange={() => toggleSelect(key)}
/> />
</TableCell> </TableCell>
<TableCell className="font-mono text-sm text-grey-1">{inv.product_code}</TableCell>
<TableCell className="font-semibold text-grey-0">{inv.product_name}</TableCell> <TableCell className="font-semibold text-grey-0">{inv.product_name}</TableCell>
<TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell> <TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell>
<TableCell className="text-sm font-mono text-grey-2">{inv.expiry_date || '-'}</TableCell>
<TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell> <TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
</TableRow> </TableRow>
); );
@@ -469,7 +470,9 @@ export default function Show({ order }: any) {
<TableHead className="w-[50px] text-center font-medium text-grey-600">#</TableHead> <TableHead className="w-[50px] text-center font-medium text-grey-600">#</TableHead>
<TableHead className="font-medium text-grey-600"> / </TableHead> <TableHead className="font-medium text-grey-600"> / </TableHead>
<TableHead className="font-medium text-grey-600"></TableHead> <TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="text-right w-32 font-medium text-grey-600"></TableHead> <TableHead className="text-right w-32 font-medium text-grey-600">
{order.status === 'completed' ? '過帳時庫存' : '可用庫存'}
</TableHead>
<TableHead className="text-right w-40 font-medium text-grey-600">調</TableHead> <TableHead className="text-right w-40 font-medium text-grey-600">調</TableHead>
<TableHead className="font-medium text-grey-600"></TableHead> <TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead> <TableHead className="font-medium text-grey-600"></TableHead>
@@ -493,7 +496,14 @@ export default function Show({ order }: any) {
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span> <span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell> <TableCell className="text-sm font-mono">
<div>{item.batch_number || '-'}</div>
{item.expiry_date && (
<div className="text-xs text-gray-400 mt-1">
: {item.expiry_date}
</div>
)}
</TableCell>
<TableCell className="text-right font-semibold text-primary-main"> <TableCell className="text-right font-semibold text-primary-main">
{item.max_quantity} {item.unit || item.unit_name} {item.max_quantity} {item.unit || item.unit_name}
</TableCell> </TableCell>
@@ -505,10 +515,10 @@ export default function Show({ order }: any) {
<Input <Input
type="number" type="number"
min="0.01" min="0.01"
step="0.01" step="any"
value={item.quantity} value={item.quantity}
onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)} onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)}
className="h-9 w-32 text-right font-medium focus:ring-primary-main" className="h-9 w-32 font-medium focus:ring-primary-main text-right"
/> />
</div> </div>
)} )}
@@ -528,7 +538,7 @@ export default function Show({ order }: any) {
</TableCell> </TableCell>
{!isReadOnly && ( {!isReadOnly && (
<TableCell className="text-center"> <TableCell className="text-center">
<Button variant="ghost" size="sm" className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 p-0" onClick={() => handleRemoveItem(index)}> <Button variant="outline" size="icon" className="button-outlined-error h-8 w-8" onClick={() => handleRemoveItem(index)}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</TableCell> </TableCell>

View File

@@ -40,6 +40,10 @@ export interface Product {
location?: string; location?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
cost_price?: number;
price?: number;
member_price?: number;
wholesale_price?: number;
} }
interface PageProps { interface PageProps {

View File

@@ -39,6 +39,8 @@ interface InventoryOption {
product_id: number; product_id: number;
product_name: string; product_name: string;
product_code: string; product_code: string;
warehouse_id: number;
warehouse_name: string;
batch_number: string; batch_number: string;
box_number: string | null; box_number: string | null;
quantity: number; quantity: number;
@@ -84,9 +86,9 @@ interface Props {
export default function ProductionCreate({ products, warehouses }: Props) { export default function ProductionCreate({ products, warehouses }: Props) {
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫 const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
// 快取對照表:warehouse_id -> inventories // 快取對照表:product_id -> inventories across warehouses
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({}); const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({}); const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
const [bomItems, setBomItems] = useState<BomItem[]>([]); const [bomItems, setBomItems] = useState<BomItem[]>([]);
@@ -107,19 +109,21 @@ export default function ProductionCreate({ products, warehouses }: Props) {
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
}); });
// 獲取倉庫資料的輔助函式 // 獲取特定商品在各倉庫的庫存分佈
const fetchWarehouseInventory = async (warehouseId: string) => { const fetchProductInventories = async (productId: string) => {
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return; if (!productId) return;
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
if (loadingProducts[productId]) return;
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: true })); setLoadingProducts(prev => ({ ...prev, [productId]: true }));
try { try {
const res = await fetch(route('api.production.warehouses.inventories', warehouseId)); const res = await fetch(route('api.production.products.inventories', productId));
const data = await res.json(); const data = await res.json();
setInventoryMap(prev => ({ ...prev, [warehouseId]: data })); setProductInventoryMap(prev => ({ ...prev, [productId]: data }));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: false })); setLoadingProducts(prev => ({ ...prev, [productId]: false }));
} }
}; };
@@ -151,33 +155,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
const updated = [...bomItems]; const updated = [...bomItems];
const item = { ...updated[index], [field]: value }; const item = { ...updated[index], [field]: value };
// 0. 當選擇來源倉庫變更時 // 1. 當選擇商品變更時 -> 載入庫存分佈並重置後續欄位
if (field === 'ui_warehouse_id') {
// 重置後續欄位
item.ui_product_id = "";
item.inventory_id = "";
item.quantity_used = "";
item.unit_id = "";
item.ui_input_quantity = "";
item.ui_selected_unit = "base";
delete item.ui_product_name;
delete item.ui_batch_number;
delete item.ui_available_qty;
delete item.ui_expiry_date;
delete item.ui_conversion_rate;
delete item.ui_base_unit_name;
delete item.ui_large_unit_name;
delete item.ui_base_unit_id;
delete item.ui_large_unit_id;
// 觸發載入資料
if (value) {
fetchWarehouseInventory(value);
}
}
// 1. 當選擇商品變更時 -> 清空批號與相關資訊
if (field === 'ui_product_id') { if (field === 'ui_product_id') {
item.ui_warehouse_id = "";
item.inventory_id = ""; item.inventory_id = "";
item.quantity_used = ""; item.quantity_used = "";
item.unit_id = ""; item.unit_id = "";
@@ -193,24 +173,43 @@ export default function ProductionCreate({ products, warehouses }: Props) {
delete item.ui_large_unit_name; delete item.ui_large_unit_name;
delete item.ui_base_unit_id; delete item.ui_base_unit_id;
delete item.ui_large_unit_id; delete item.ui_large_unit_id;
if (value) {
const prod = products.find(p => String(p.id) === value);
if (prod) {
item.ui_product_name = prod.name;
item.ui_base_unit_name = prod.base_unit?.name || '';
}
fetchProductInventories(value);
}
} }
// 2. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊 // 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊
if (field === 'ui_warehouse_id') {
item.inventory_id = "";
item.quantity_used = "";
item.unit_id = "";
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) { if (field === 'inventory_id' && value) {
const currentOptions = inventoryMap[item.ui_warehouse_id] || []; const currentOptions = productInventoryMap[item.ui_product_id] || [];
const inv = currentOptions.find(i => String(i.id) === value); const inv = currentOptions.find(i => String(i.id) === value);
if (inv) { if (inv) {
item.ui_product_id = String(inv.product_id); // 確保商品也被選中 (雖通常是先選商品) item.ui_warehouse_id = String(inv.warehouse_id);
item.ui_product_name = inv.product_name;
item.ui_batch_number = inv.batch_number; item.ui_batch_number = inv.batch_number;
item.ui_available_qty = inv.quantity; item.ui_available_qty = inv.quantity;
item.ui_expiry_date = inv.expiry_date || ''; item.ui_expiry_date = inv.expiry_date || '';
// 單位與轉換率 // 單位與轉換率
item.ui_base_unit_name = inv.base_unit_name || inv.unit_name || ''; item.ui_base_unit_name = inv.unit_name || '';
item.ui_large_unit_name = inv.large_unit_name || '';
item.ui_base_unit_id = inv.base_unit_id; item.ui_base_unit_id = inv.base_unit_id;
item.ui_large_unit_id = inv.large_unit_id;
item.ui_conversion_rate = inv.conversion_rate || 1; item.ui_conversion_rate = inv.conversion_rate || 1;
// 預設單位 // 預設單位
@@ -219,16 +218,13 @@ export default function ProductionCreate({ products, warehouses }: Props) {
} }
} }
// 3. 計算最終數量 (Base Quantity) // 4. 計算最終數量 (Base Quantity)
// 當 輸入數量 或 選擇單位 變更時
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') { if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
const inputQty = parseFloat(item.ui_input_quantity || '0'); const inputQty = parseFloat(item.ui_input_quantity || '0');
const rate = item.ui_conversion_rate || 1; const rate = item.ui_conversion_rate || 1;
if (item.ui_selected_unit === 'large') { if (item.ui_selected_unit === 'large') {
item.quantity_used = String(inputQty * rate); item.quantity_used = String(inputQty * rate);
// 注意:後端需要的是 Base Unit ID? 這裡我們都送 Base Unit ID因為 quantity_used 是 Base Unit
// 但為了保留 User 的選擇,我們可能可以在 remark 註記? 目前先從簡
item.unit_id = String(item.ui_base_unit_id || ''); item.unit_id = String(item.ui_base_unit_id || '');
} else { } else {
item.quantity_used = String(inputQty); item.quantity_used = String(inputQty);
@@ -256,17 +252,21 @@ export default function ProductionCreate({ products, warehouses }: Props) {
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1; const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
// 自動帶入配方標準產量 // 自動帶入配方標準產量
setData('output_quantity', String(yieldQty)); setData('output_quantity', String(yieldQty));
const ratio = 1;
const newBomItems: BomItem[] = recipe.items.map((item: any) => { const newBomItems: BomItem[] = recipe.items.map((item: any) => {
const baseQty = parseFloat(item.quantity || "0"); const baseQty = parseFloat(item.quantity || "0");
const calculatedQty = (baseQty * ratio).toFixed(4); // 保持精度 const calculatedQty = baseQty; // 保持精度
// 若有配方商品,預先載入庫存分佈
if (item.product_id) {
fetchProductInventories(String(item.product_id));
}
return { return {
inventory_id: "", inventory_id: "",
quantity_used: String(calculatedQty), quantity_used: String(calculatedQty),
unit_id: String(item.unit_id), unit_id: String(item.unit_id),
ui_warehouse_id: selectedWarehouse || "", // 自動帶入目前選擇的倉庫 ui_warehouse_id: "",
ui_product_id: String(item.product_id), ui_product_id: String(item.product_id),
ui_product_name: item.product_name, ui_product_name: item.product_name,
ui_batch_number: "", ui_batch_number: "",
@@ -280,11 +280,6 @@ export default function ProductionCreate({ products, warehouses }: Props) {
}); });
setBomItems(newBomItems); setBomItems(newBomItems);
// 若有選倉庫,預先載入庫存資料以供選擇
if (selectedWarehouse) {
fetchWarehouseInventory(selectedWarehouse);
}
toast.success(`已自動載入配方: ${recipe.name}`, { toast.success(`已自動載入配方: ${recipe.name}`, {
description: `標準產量: ${yieldQty}` description: `標準產量: ${yieldQty}`
}); });
@@ -503,7 +498,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
<Label className="text-xs font-medium text-grey-2"> *</Label> <Label className="text-xs font-medium text-grey-2"> *</Label>
<Input <Input
type="number" type="number"
step="0.01" step="any"
value={data.output_quantity} value={data.output_quantity}
onChange={(e) => setData('output_quantity', e.target.value)} onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50" placeholder="例如: 50"
@@ -607,8 +602,8 @@ export default function ProductionCreate({ products, warehouses }: Props) {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-gray-50/50"> <TableRow className="bg-gray-50/50">
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[25%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[25%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"></TableHead> <TableHead className="w-[15%]"></TableHead>
@@ -617,61 +612,72 @@ export default function ProductionCreate({ products, warehouses }: Props) {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{bomItems.map((item, index) => { {bomItems.map((item, index) => {
// 取得此列已載入的 Inventory Options // 1. 商品選項
const currentOptions = inventoryMap[item.ui_warehouse_id] || []; const productOptions = products.map(p => ({
label: `${p.name} (${p.code})`,
value: String(p.id)
}));
// 過濾商品 // 2. 來源倉庫選項 (根據商品库庫存過濾)
const uniqueProductOptions = Array.from(new Map( const currentInventories = productInventoryMap[item.ui_product_id] || [];
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }]) const filteredWarehouseOptions = Array.from(new Map(
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }])
).values()); ).values());
// 過濾批號 // 如果篩選後沒有倉庫(即該商品無庫存),則顯示所有倉庫以供選取(或顯示無庫存提示)
const batchOptions = currentOptions const uniqueWarehouseOptions = filteredWarehouseOptions.length > 0
.filter(inv => String(inv.product_id) === item.ui_product_id) ? filteredWarehouseOptions
.map(inv => ({ : (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
// 3. 批號選項 (根據商品與倉庫過濾)
const batchOptions = currentInventories
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
.map((inv: InventoryOption) => ({
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`, label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
value: String(inv.id) value: String(inv.id)
})); }));
return ( return (
<TableRow key={index}> <TableRow key={index}>
{/* 0. 選擇來源倉庫 */}
<TableCell className="align-top">
<SearchableSelect
value={item.ui_warehouse_id}
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
options={warehouses.map(w => ({ label: w.name, value: String(w.id) }))}
placeholder="選擇倉庫"
className="w-full"
/>
</TableCell>
{/* 1. 選擇商品 */} {/* 1. 選擇商品 */}
<TableCell className="align-top"> <TableCell className="align-top">
<SearchableSelect <SearchableSelect
value={item.ui_product_id} value={item.ui_product_id}
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)} onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
options={uniqueProductOptions} options={productOptions}
placeholder="選擇商品" placeholder="選擇商品"
className="w-full" className="w-full"
disabled={!item.ui_warehouse_id}
/> />
</TableCell> </TableCell>
{/* 2. 選擇批號 */} {/* 2. 選擇來源倉庫 */}
<TableCell className="align-top">
<SearchableSelect
value={item.ui_warehouse_id}
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
options={uniqueWarehouseOptions as any}
placeholder={item.ui_product_id
? (loadingProducts[item.ui_product_id]
? "載入庫存中..."
: (uniqueWarehouseOptions.length === 0 ? "該商品目前無庫存" : "選擇倉庫"))
: "請先選商品"}
className="w-full"
disabled={!item.ui_product_id || (loadingProducts[item.ui_product_id])}
/>
</TableCell>
{/* 3. 選擇批號 */}
<TableCell className="align-top"> <TableCell className="align-top">
<SearchableSelect <SearchableSelect
value={item.inventory_id} value={item.inventory_id}
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)} onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
options={batchOptions} options={batchOptions as any}
placeholder={item.ui_product_id ? "選擇批號" : "請先選商品"} placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
className="w-full" className="w-full"
disabled={!item.ui_product_id} disabled={!item.ui_warehouse_id}
/> />
{item.inventory_id && (() => { {item.inventory_id && (() => {
const selectedInv = currentOptions.find(i => String(i.id) === item.inventory_id); const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
if (selectedInv) return ( if (selectedInv) return (
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-500 mt-1">
: {selectedInv.expiry_date || '無'} | : {selectedInv.quantity} : {selectedInv.expiry_date || '無'} | : {selectedInv.quantity}
@@ -685,11 +691,11 @@ export default function ProductionCreate({ products, warehouses }: Props) {
<TableCell className="align-top"> <TableCell className="align-top">
<Input <Input
type="number" type="number"
step="1" step="any"
value={item.ui_input_quantity} value={item.ui_input_quantity}
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)} onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
placeholder="0" placeholder="0"
className="h-9" className="h-9 text-right"
disabled={!item.inventory_id} disabled={!item.inventory_id}
/> />
</TableCell> </TableCell>

View File

@@ -38,6 +38,8 @@ interface InventoryOption {
product_id: number; product_id: number;
product_name: string; product_name: string;
product_code: string; product_code: string;
warehouse_id: number;
warehouse_name: string;
batch_number: string; batch_number: string;
box_number: string | null; box_number: string | null;
quantity: number; quantity: number;
@@ -73,6 +75,7 @@ interface BomItem {
ui_large_unit_name?: string; ui_large_unit_name?: string;
ui_base_unit_id?: number; ui_base_unit_id?: number;
ui_large_unit_id?: number; ui_large_unit_id?: number;
ui_product_code?: string;
} }
interface ProductionOrderItem { interface ProductionOrderItem {
@@ -136,23 +139,24 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "" productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
); // 產出倉庫 ); // 產出倉庫
// 快取對照表:warehouse_id -> inventories // 快取對照表:product_id -> inventories
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({}); const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({}); const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
// 獲取倉庫資料的輔助函式 // 獲取商品所有倉庫庫存的分佈
const fetchWarehouseInventory = async (warehouseId: string) => { const fetchProductInventories = async (productId: string) => {
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return; if (!productId) return;
if (loadingProducts[productId]) return;
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: true })); setLoadingProducts(prev => ({ ...prev, [productId]: true }));
try { try {
const res = await fetch(route('api.production.warehouses.inventories', warehouseId)); const res = await fetch(route('api.production.products.inventories', productId));
const data = await res.json(); const data = await res.json();
setInventoryMap(prev => ({ ...prev, [warehouseId]: data })); setProductInventoryMap(prev => ({ ...prev, [productId]: data }));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: false })); setLoadingProducts(prev => ({ ...prev, [productId]: false }));
} }
}; };
@@ -188,25 +192,25 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
}); });
// 初始化載入既有 BOM 的來源倉庫資料 // 初始化載入既有 BOM 的商品庫存資料
useEffect(() => { useEffect(() => {
initialBomItems.forEach(item => { initialBomItems.forEach(item => {
if (item.ui_warehouse_id) { if (item.ui_product_id) {
fetchWarehouseInventory(item.ui_warehouse_id); fetchProductInventories(item.ui_product_id);
} }
}); });
}, []); }, []);
// 當 inventoryOptions (Map) 載入後,更新現有 BOM items 的詳細資訊 (如單位、轉換率) // 當 inventoryOptions 載入後,更新現有 BOM items 的詳細資訊
// 監聽 inventoryMap 變更
useEffect(() => { useEffect(() => {
setBomItems(prevItems => prevItems.map(item => { setBomItems(prevItems => prevItems.map(item => {
if (item.ui_warehouse_id && inventoryMap[item.ui_warehouse_id] && item.inventory_id && !item.ui_conversion_rate) { const currentOptions = productInventoryMap[item.ui_product_id] || [];
const inv = inventoryMap[item.ui_warehouse_id].find(i => String(i.id) === item.inventory_id); if (currentOptions.length > 0 && item.inventory_id && !item.ui_conversion_rate) {
const inv = currentOptions.find(i => String(i.id) === item.inventory_id);
if (inv) { if (inv) {
return { return {
...item, ...item,
ui_product_id: String(inv.product_id), ui_warehouse_id: String(inv.warehouse_id), // 重要:還原倉庫 ID
ui_product_name: inv.product_name, ui_product_name: inv.product_name,
ui_batch_number: inv.batch_number, ui_batch_number: inv.batch_number,
ui_available_qty: inv.quantity, ui_available_qty: inv.quantity,
@@ -221,7 +225,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
} }
return item; return item;
})); }));
}, [inventoryMap]); }, [productInventoryMap]);
// 同步 warehouse_id 到 form data // 同步 warehouse_id 到 form data
useEffect(() => { useEffect(() => {
@@ -251,53 +255,40 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
const updated = [...bomItems]; const updated = [...bomItems];
const item = { ...updated[index], [field]: value }; const item = { ...updated[index], [field]: value };
// 0. 當選擇來源倉庫變更時 // 0. 當選擇商品變更時 (第一層)
if (field === 'ui_warehouse_id') {
item.ui_product_id = "";
item.inventory_id = "";
item.quantity_used = "";
item.unit_id = "";
item.ui_input_quantity = "";
item.ui_selected_unit = "base";
delete item.ui_product_name;
delete item.ui_batch_number;
delete item.ui_available_qty;
delete item.ui_expiry_date;
delete item.ui_conversion_rate;
delete item.ui_base_unit_name;
delete item.ui_large_unit_name;
delete item.ui_base_unit_id;
delete item.ui_large_unit_id;
if (value) {
fetchWarehouseInventory(value);
}
}
// 1. 當選擇商品變更時 -> 清空批號與相關資訊
if (field === 'ui_product_id') { if (field === 'ui_product_id') {
item.ui_warehouse_id = "";
item.inventory_id = ""; item.inventory_id = "";
item.quantity_used = ""; item.quantity_used = "";
item.unit_id = ""; item.unit_id = "";
item.ui_input_quantity = ""; item.ui_input_quantity = "";
item.ui_selected_unit = "base"; item.ui_selected_unit = "base";
delete item.ui_product_name; // 保留基本資訊
delete item.ui_batch_number; if (value) {
delete item.ui_available_qty; const prod = products.find(p => String(p.id) === value);
delete item.ui_expiry_date; if (prod) {
delete item.ui_conversion_rate; item.ui_product_name = prod.name;
delete item.ui_base_unit_name; item.ui_base_unit_name = prod.base_unit?.name || '';
delete item.ui_large_unit_name; }
delete item.ui_base_unit_id; fetchProductInventories(value);
delete item.ui_large_unit_id; }
} }
// 2. 當選擇批號變更時 // 1. 當選擇來源倉庫變更時 (第二層)
if (field === 'ui_warehouse_id') {
item.inventory_id = "";
item.quantity_used = "";
item.unit_id = "";
item.ui_input_quantity = "";
item.ui_selected_unit = "base";
}
// 2. 當選擇批號 (Inventory) 變更時 (第三層)
if (field === 'inventory_id' && value) { if (field === 'inventory_id' && value) {
const currentOptions = inventoryMap[item.ui_warehouse_id] || []; const currentOptions = productInventoryMap[item.ui_product_id] || [];
const inv = currentOptions.find(i => String(i.id) === value); const inv = currentOptions.find(i => String(i.id) === value);
if (inv) { if (inv) {
item.ui_product_id = String(inv.product_id); item.ui_warehouse_id = String(inv.warehouse_id);
item.ui_product_name = inv.product_name; item.ui_product_name = inv.product_name;
item.ui_batch_number = inv.batch_number; item.ui_batch_number = inv.batch_number;
item.ui_available_qty = inv.quantity; item.ui_available_qty = inv.quantity;
@@ -471,7 +462,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
<Label className="text-xs font-medium text-grey-2"> *</Label> <Label className="text-xs font-medium text-grey-2"> *</Label>
<Input <Input
type="number" type="number"
step="0.01" step="any"
value={data.output_quantity} value={data.output_quantity}
onChange={(e) => setData('output_quantity', e.target.value)} onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50" placeholder="例如: 50"
@@ -583,8 +574,8 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-gray-50/50"> <TableRow className="bg-gray-50/50">
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[25%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[25%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"></TableHead> <TableHead className="w-[15%]"></TableHead>
@@ -593,19 +584,31 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{bomItems.map((item, index) => { {bomItems.map((item, index) => {
// 取得此列已載入的 Inventory Options // 1. 商品選項
const currentOptions = inventoryMap[item.ui_warehouse_id] || []; const productOptions = products.map(p => ({
label: `${p.name} (${p.code})`,
value: String(p.id)
}));
const uniqueProductOptions = Array.from(new Map( // 2. 來源倉庫選項 (根據商品库庫存過濾)
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }]) const currentInventories = productInventoryMap[item.ui_product_id] || [];
const filteredWarehouseOptions = Array.from(new Map(
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }])
).values()); ).values());
// 在獲取前初始狀態的備案 const uniqueWarehouseOptions = filteredWarehouseOptions.length > 0
const displayProductOptions = uniqueProductOptions.length > 0 ? uniqueProductOptions : (item.ui_product_name ? [{ label: item.ui_product_name, value: item.ui_product_id }] : []); ? filteredWarehouseOptions
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
const batchOptions = currentOptions // 備案 (初始載入時)
.filter(inv => String(inv.product_id) === item.ui_product_id) const displayWarehouseOptions = uniqueWarehouseOptions.length > 0
.map(inv => ({ ? uniqueWarehouseOptions
: (item.ui_warehouse_id ? [{ label: "載入中...", value: item.ui_warehouse_id }] : []);
// 3. 批號選項 (根據商品與倉庫過濾)
const batchOptions = currentInventories
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
.map((inv: InventoryOption) => ({
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`, label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
value: String(inv.id) value: String(inv.id)
})); }));
@@ -614,44 +617,47 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []); const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
return ( return (
<TableRow key={index}> <TableRow key={index}>
{/* 0. 選擇來源倉庫 */}
<TableCell className="align-top">
<SearchableSelect
value={item.ui_warehouse_id}
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
options={warehouses.map(w => ({ label: w.name, value: String(w.id) }))}
placeholder="選擇倉庫"
className="w-full"
/>
</TableCell>
{/* 1. 選擇商品 */} {/* 1. 選擇商品 */}
<TableCell className="align-top"> <TableCell className="align-top">
<SearchableSelect <SearchableSelect
value={item.ui_product_id} value={item.ui_product_id}
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)} onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
options={displayProductOptions} options={productOptions}
placeholder="選擇商品" placeholder="選擇商品"
className="w-full" className="w-full"
disabled={!item.ui_warehouse_id}
/> />
</TableCell> </TableCell>
{/* 2. 選擇批號 */} {/* 2. 選擇來源倉庫 */}
<TableCell className="align-top">
<SearchableSelect
value={item.ui_warehouse_id}
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
options={displayWarehouseOptions as any}
placeholder={item.ui_product_id
? (loadingProducts[item.ui_product_id]
? "載入庫存中..."
: (uniqueWarehouseOptions.length === 0 ? "該商品目前無庫存" : "選擇倉庫"))
: "請先選商品"}
className="w-full"
disabled={!item.ui_product_id || (loadingProducts[item.ui_product_id])}
/>
</TableCell>
{/* 3. 選擇批號 */}
<TableCell className="align-top"> <TableCell className="align-top">
<SearchableSelect <SearchableSelect
value={item.inventory_id} value={item.inventory_id}
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)} onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
options={displayBatchOptions} options={displayBatchOptions as any}
placeholder={item.ui_product_id ? "選擇批號" : "請先選商品"} placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
className="w-full" className="w-full"
disabled={!item.ui_product_id} disabled={!item.ui_warehouse_id}
/> />
{item.inventory_id && (() => { {item.inventory_id && (() => {
const selectedInv = currentOptions.find(i => String(i.id) === item.inventory_id); const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
if (selectedInv) return ( if (selectedInv) return (
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-500 mt-1">
: {selectedInv.expiry_date || '無'} | : {selectedInv.quantity} : {selectedInv.expiry_date || '無'} | : {selectedInv.quantity}
@@ -665,11 +671,11 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
<TableCell className="align-top"> <TableCell className="align-top">
<Input <Input
type="number" type="number"
step="1" step="any"
value={item.ui_input_quantity} value={item.ui_input_quantity}
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)} onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
placeholder="0" placeholder="0"
className="h-9" className="h-9 text-right"
disabled={!item.inventory_id} disabled={!item.inventory_id}
/> />
</TableCell> </TableCell>

View File

@@ -2,12 +2,11 @@
* 新增配方頁面 * 新增配方頁面
*/ */
import { useState, useEffect } from "react"; import { useEffect } from "react";
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react'; import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm, Link } from "@inertiajs/react"; import { Head, useForm, Link } from "@inertiajs/react";
import { toast } from "sonner";
import { getBreadcrumbs } from "@/utils/breadcrumb"; import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
@@ -36,6 +35,7 @@ interface RecipeItem {
// UI Helpers // UI Helpers
ui_product_name?: string; ui_product_name?: string;
ui_product_code?: string; ui_product_code?: string;
ui_unit_name?: string;
} }
interface Props { interface Props {
@@ -91,9 +91,11 @@ export default function RecipeCreate({ products, units }: Props) {
if (product) { if (product) {
newItems[index].ui_product_name = product.name; newItems[index].ui_product_name = product.name;
newItems[index].ui_product_code = product.code; newItems[index].ui_product_code = product.code;
// Default to base unit // Default to base unit and fix it
if (product.base_unit_id) { if (product.base_unit_id) {
newItems[index].unit_id = String(product.base_unit_id); newItems[index].unit_id = String(product.base_unit_id);
const unit = units.find(u => u.id === product.base_unit_id);
newItems[index].ui_unit_name = unit?.name;
} }
} }
} }
@@ -103,14 +105,7 @@ export default function RecipeCreate({ products, units }: Props) {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
post(route('recipes.store'), { post(route('recipes.store'));
onSuccess: () => {
toast.success("配方已建立");
},
onError: (errors) => {
toast.error("儲存失敗,請檢查欄位");
}
});
}; };
return ( return (
@@ -195,6 +190,7 @@ export default function RecipeCreate({ products, units }: Props) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
type="number" type="number"
step="any"
value={data.yield_quantity} value={data.yield_quantity}
onChange={(e) => setData('yield_quantity', e.target.value)} onChange={(e) => setData('yield_quantity', e.target.value)}
placeholder="1" placeholder="1"
@@ -269,23 +265,17 @@ export default function RecipeCreate({ products, units }: Props) {
<TableCell className="align-top"> <TableCell className="align-top">
<Input <Input
type="number" type="number"
step="0.0001" step="any"
value={item.quantity} value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', e.target.value)} onChange={(e) => updateItem(index, 'quantity', e.target.value)}
placeholder="數量" placeholder="數量"
className="text-right"
/> />
</TableCell> </TableCell>
<TableCell className="align-top"> <TableCell className="align-middle">
<SearchableSelect <div className="text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-md border border-gray-100 min-h-[38px] flex items-center">
value={item.unit_id} {item.ui_unit_name || (units.find(u => String(u.id) === item.unit_id)?.name) || '-'}
onValueChange={(v) => updateItem(index, 'unit_id', v)} </div>
options={units.map(u => ({
label: u.name,
value: String(u.id)
}))}
placeholder="單位"
className="w-full"
/>
</TableCell> </TableCell>
<TableCell className="align-top"> <TableCell className="align-top">
<Input <Input

View File

@@ -2,12 +2,11 @@
* 編輯配方頁面 * 編輯配方頁面
*/ */
import { useState, useEffect } from "react"; import { useEffect } from "react";
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react'; import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm, Link } from "@inertiajs/react"; import { Head, useForm, Link } from "@inertiajs/react";
import { toast } from "sonner";
import { getBreadcrumbs } from "@/utils/breadcrumb"; import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
@@ -59,6 +58,7 @@ interface RecipeItemForm {
// UI Helpers // UI Helpers
ui_product_name?: string; ui_product_name?: string;
ui_product_code?: string; ui_product_code?: string;
ui_unit_name?: string;
} }
interface Props { interface Props {
@@ -80,7 +80,8 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
unit_id: String(item.unit_id), unit_id: String(item.unit_id),
remark: item.remark || "", remark: item.remark || "",
ui_product_name: item.product?.name, ui_product_name: item.product?.name,
ui_product_code: item.product?.code ui_product_code: item.product?.code,
ui_unit_name: item.unit?.name
})) as RecipeItemForm[], })) as RecipeItemForm[],
}); });
@@ -118,6 +119,8 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
// Default to base unit if not set // Default to base unit if not set
if (product.base_unit_id) { if (product.base_unit_id) {
newItems[index].unit_id = String(product.base_unit_id); newItems[index].unit_id = String(product.base_unit_id);
const unit = units.find(u => u.id === product.base_unit_id);
newItems[index].ui_unit_name = unit?.name;
} }
} }
} }
@@ -127,14 +130,7 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
put(route('recipes.update', recipe.id), { put(route('recipes.update', recipe.id));
onSuccess: () => {
toast.success("配方已更新");
},
onError: (errors) => {
toast.error("儲存失敗,請檢查欄位");
}
});
}; };
return ( return (
@@ -219,6 +215,7 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
type="number" type="number"
step="any"
value={data.yield_quantity} value={data.yield_quantity}
onChange={(e) => setData('yield_quantity', e.target.value)} onChange={(e) => setData('yield_quantity', e.target.value)}
placeholder="1" placeholder="1"
@@ -293,23 +290,17 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
<TableCell className="align-top"> <TableCell className="align-top">
<Input <Input
type="number" type="number"
step="0.0001" step="any"
value={item.quantity} value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', e.target.value)} onChange={(e) => updateItem(index, 'quantity', e.target.value)}
placeholder="數量" placeholder="數量"
className="text-right"
/> />
</TableCell> </TableCell>
<TableCell className="align-top"> <TableCell className="align-middle">
<SearchableSelect <div className="text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-md border border-gray-100 min-h-[38px] flex items-center">
value={item.unit_id} {item.ui_unit_name || (units.find(u => String(u.id) === item.unit_id)?.name) || '-'}
onValueChange={(v) => updateItem(index, 'unit_id', v)} </div>
options={units.map(u => ({
label: u.name,
value: String(u.id)
}))}
placeholder="單位"
className="w-full"
/>
</TableCell> </TableCell>
<TableCell className="align-top"> <TableCell className="align-top">
<Input <Input

View File

@@ -90,7 +90,7 @@ export default function CreatePurchaseOrder({
} }
if (!orderDate) { if (!orderDate) {
toast.error("請選擇採購日期"); toast.error("請選擇下單日期");
return; return;
} }
@@ -247,7 +247,7 @@ export default function CreatePurchaseOrder({
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-3"> <div className="space-y-3">
<label className="text-sm font-bold text-gray-700"> <label className="text-sm font-bold text-gray-700">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</label> </label>
<Input <Input
type="date" type="date"
@@ -342,7 +342,7 @@ export default function CreatePurchaseOrder({
onChange={(e) => setInvoiceAmount(e.target.value)} onChange={(e) => setInvoiceAmount(e.target.value)}
placeholder="0" placeholder="0"
min="0" min="0"
step="0.01" step="any"
className="block w-full" className="block w-full"
/> />
{invoiceAmount && totalAmount > 0 && parseFloat(invoiceAmount) !== totalAmount && ( {invoiceAmount && totalAmount > 0 && parseFloat(invoiceAmount) !== totalAmount && (
@@ -419,6 +419,7 @@ export default function CreatePurchaseOrder({
<div className="relative w-32"> <div className="relative w-32">
<Input <Input
type="number" type="number"
step="any"
value={taxAmount} value={taxAmount}
onChange={(e) => { onChange={(e) => {
setTaxAmount(e.target.value); setTaxAmount(e.target.value);

View File

@@ -89,7 +89,7 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
<span className="font-medium text-gray-900">{formatDateTime(order.createdAt)}</span> <span className="font-medium text-gray-900">{formatDateTime(order.createdAt)}</span>
</div> </div>
<div> <div>
<span className="text-sm text-gray-500 block mb-1"></span> <span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{order.orderDate || "-"}</span> <span className="font-medium text-gray-900">{order.orderDate || "-"}</span>
</div> </div>
<div> <div>

View File

@@ -0,0 +1,334 @@
import { useState, useEffect } from "react";
import { ArrowLeft, Plus, Trash2, Package, Info, Calculator } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { toast } from "sonner";
import axios from "axios";
interface Product {
id: number;
name: string;
code: string;
unit_name: string;
}
interface Item {
product_id: number | null;
product_name?: string;
product_code?: string;
unit_name?: string;
batch_number: string;
quantity: number;
unit_price: number;
subtotal: number;
remark: string;
available_batches: any[];
}
interface Props {
order?: any;
warehouses: { id: number; name: string }[];
products: Product[];
}
export default function ShippingOrderCreate({ order, warehouses, products }: Props) {
const isEdit = !!order;
const [warehouseId, setWarehouseId] = useState<string>(order?.warehouse_id?.toString() || "");
const [customerName, setCustomerName] = useState(order?.customer_name || "");
const [shippingDate, setShippingDate] = useState(order?.shipping_date || new Date().toISOString().split('T')[0]);
const [remarks, setRemarks] = useState(order?.remarks || "");
const [items, setItems] = useState<Item[]>(order?.items?.map((item: any) => ({
product_id: item.product_id,
batch_number: item.batch_number,
quantity: Number(item.quantity),
unit_price: Number(item.unit_price),
subtotal: Number(item.subtotal),
remark: item.remark || "",
available_batches: [],
})) || []);
const [taxAmount, setTaxAmount] = useState(Number(order?.tax_amount) || 0);
const totalAmount = items.reduce((sum, item) => sum + item.subtotal, 0);
const grandTotal = totalAmount + taxAmount;
// 當品項變動時,自動計算稅額 (預設 5%)
useEffect(() => {
if (!isEdit || (isEdit && order.status === 'draft')) {
setTaxAmount(Math.round(totalAmount * 0.05));
}
}, [totalAmount]);
const addItem = () => {
setItems([...items, {
product_id: null,
batch_number: "",
quantity: 1,
unit_price: 0,
subtotal: 0,
remark: "",
available_batches: [],
}]);
};
const removeItem = (index: number) => {
setItems(items.filter((_, i) => i !== index));
};
const updateItem = (index: number, updates: Partial<Item>) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], ...updates };
// 計算小計
if ('quantity' in updates || 'unit_price' in updates) {
newItems[index].subtotal = Number(newItems[index].quantity) * Number(newItems[index].unit_price);
}
setItems(newItems);
// 如果商品變動,抓取批號
if ('product_id' in updates && updates.product_id && warehouseId) {
fetchBatches(index, updates.product_id, warehouseId);
}
};
const fetchBatches = async (index: number, productId: number, wId: string) => {
try {
const response = await axios.get(route('api.warehouses.inventory.batches', { warehouse: wId, productId }));
const newItems = [...items];
newItems[index].available_batches = response.data;
setItems(newItems);
} catch (error) {
console.error("Failed to fetch batches", error);
}
};
const handleSave = () => {
if (!warehouseId) {
toast.error("請選擇出貨倉庫");
return;
}
if (!shippingDate) {
toast.error("請選擇出貨日期");
return;
}
if (items.length === 0) {
toast.error("請至少新增一個品項");
return;
}
const data = {
warehouse_id: warehouseId,
customer_name: customerName,
shipping_date: shippingDate,
remarks: remarks,
total_amount: totalAmount,
tax_amount: taxAmount,
grand_total: grandTotal,
items: items.map(item => ({
product_id: item.product_id,
batch_number: item.batch_number,
quantity: item.quantity,
unit_price: item.unit_price,
subtotal: item.subtotal,
remark: item.remark,
})),
};
if (isEdit) {
router.put(route('delivery-notes.update', order.id), data);
} else {
router.post(route('delivery-notes.store'), data);
}
};
return (
<AuthenticatedLayout breadcrumbs={[
{ label: '供應鏈管理', href: '#' },
{ label: '出貨單管理', href: route('delivery-notes.index') },
{ label: isEdit ? '編輯出貨單' : '建立出貨單', isPage: true }
] as any}>
<Head title={isEdit ? "編輯出貨單" : "建立出貨單"} />
<div className="container mx-auto p-6 max-w-7xl">
<div className="mb-6">
<Link href={route('delivery-notes.index')}>
<Button variant="outline" className="gap-2 button-outlined-primary mb-4">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Package className="h-6 w-6 text-primary-main" />
{isEdit ? `編輯出貨單 ${order.doc_no}` : "建立新出貨單"}
</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左側:基本資訊 */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
<Info className="h-5 w-5 text-primary-main" />
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium"> *</label>
<SearchableSelect
value={warehouseId}
onValueChange={setWarehouseId}
options={warehouses.map(w => ({ label: w.name, value: w.id.toString() }))}
placeholder="選擇倉庫"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"> *</label>
<Input
type="date"
value={shippingDate}
onChange={e => setShippingDate(e.target.value)}
/>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-medium"></label>
<Input
placeholder="輸入客戶或專案名稱"
value={customerName}
onChange={e => setCustomerName(e.target.value)}
/>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-medium"></label>
<Textarea
placeholder="其他說明..."
value={remarks}
onChange={e => setRemarks(e.target.value)}
rows={3}
/>
</div>
</div>
</div>
{/* 品項明細 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold flex items-center gap-2">
<Package className="h-5 w-5 text-primary-main" />
</h2>
<Button onClick={addItem} size="sm" className="button-filled-primary gap-1">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-gray-50">
<th className="p-2 text-left w-[250px]"></th>
<th className="p-2 text-left w-[180px]"></th>
<th className="p-2 text-left w-[120px]"></th>
<th className="p-2 text-left w-[120px]"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-right"></th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item, index) => (
<tr key={index}>
<td className="p-2">
<SearchableSelect
value={item.product_id?.toString() || ""}
onValueChange={(val) => updateItem(index, { product_id: parseInt(val) })}
options={products.map(p => ({ label: `[${p.code}] ${p.name}`, value: p.id.toString() }))}
placeholder="選擇商品"
/>
</td>
<td className="p-2">
<SearchableSelect
value={item.batch_number}
disabled={!item.product_id || !warehouseId}
onValueChange={(val) => updateItem(index, { batch_number: val })}
options={item.available_batches.map(b => ({ label: `${b.batch_number} (剩餘 ${b.quantity})`, value: b.batch_number }))}
placeholder="選擇批號"
/>
</td>
<td className="p-2">
<Input
type="number"
value={item.quantity}
onChange={e => updateItem(index, { quantity: parseFloat(e.target.value) || 0 })}
min={0}
step="any"
className="text-right"
/>
</td>
<td className="p-2">
<Input
type="number"
value={item.unit_price}
onChange={e => updateItem(index, { unit_price: parseFloat(e.target.value) || 0 })}
min={0}
step="any"
className="text-right"
/>
</td>
<td className="p-2 font-medium">
${item.subtotal.toLocaleString()}
</td>
<td className="p-2 text-right">
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} className="text-red-500 hover:text-red-700">
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
{items.length === 0 && (
<div className="text-center py-8 text-gray-500"></div>
)}
</div>
</div>
</div>
{/* 右側:金額總計 */}
<div className="space-y-6">
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 sticky top-6">
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
<Calculator className="h-5 w-5 text-primary-main" />
</h2>
<div className="space-y-4">
<div className="flex justify-between text-sm text-gray-600">
<span></span>
<span>${totalAmount.toLocaleString()}</span>
</div>
<div className="flex justify-between items-center text-sm text-gray-600">
<span> (5%)</span>
<div className="w-24">
<Input
type="number"
value={taxAmount}
onChange={e => setTaxAmount(parseFloat(e.target.value) || 0)}
step="any"
className="h-8 text-right p-1"
/>
</div>
</div>
<div className="border-t pt-4 flex justify-between font-bold text-lg">
<span></span>
<span className="text-primary-main">${grandTotal.toLocaleString()}</span>
</div>
<Button onClick={handleSave} className="w-full button-filled-primary mt-4 py-6 text-lg font-bold">
{isEdit ? "更新出貨單" : "建立出貨單"}
</Button>
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,207 @@
import { useState, useEffect } from "react";
import { Plus, Package, Search, RotateCcw, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link } from "@inertiajs/react";
import Pagination from "@/Components/shared/Pagination";
import { Can } from "@/Components/Permission/Can";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
interface Props {
orders: {
data: any[];
links: any[];
};
filters: {
search?: string;
status?: string;
};
warehouses: { id: number; name: string }[];
}
export default function ShippingOrderIndex({ orders, filters, warehouses }: Props) {
const [search, setSearch] = useState(filters.search || "");
const [status, setStatus] = useState<string>(filters.status || "all");
const handleFilter = () => {
router.get(
route('delivery-notes.index'),
{
search,
status: status === 'all' ? undefined : status,
},
{ preserveState: true, replace: true }
);
};
const handleReset = () => {
setSearch("");
setStatus("all");
router.get(route('delivery-notes.index'));
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <Badge variant="secondary">稿</Badge>;
case 'completed':
return <Badge className="bg-green-100 text-green-800"></Badge>;
case 'cancelled':
return <Badge variant="destructive"></Badge>;
default:
return <Badge>{status}</Badge>;
}
};
return (
<AuthenticatedLayout breadcrumbs={[
{ label: '供應鏈管理', href: '#' },
{ label: '出貨單管理', href: route('delivery-notes.index'), isPage: true }
] as any}>
<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">
<Package className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
<div className="flex gap-2">
<Can permission="delivery_notes.create">
<Button
onClick={() => router.get(route('delivery-notes.create'))}
className="gap-2 button-filled-primary"
>
<Plus className="h-4 w-4" />
</Button>
</Can>
</div>
</div>
<div className="bg-white p-5 rounded-lg shadow-sm border border-gray-200 mb-6">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
<div className="md:col-span-6 space-y-1">
<Label className="text-xs font-medium text-grey-1"></Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="搜尋單號、客戶名稱..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 h-9 block"
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
/>
</div>
</div>
<div className="md:col-span-4 space-y-1">
<Label className="text-xs font-medium text-grey-1"></Label>
<SearchableSelect
value={status}
onValueChange={setStatus}
options={[
{ label: "全部狀態", value: "all" },
{ label: "草稿", value: "draft" },
{ label: "已過帳", value: "completed" },
{ label: "已取消", value: "cancelled" },
]}
className="h-9"
showSearch={false}
/>
</div>
<div className="md:col-span-2 flex items-end gap-2">
<Button
variant="outline"
onClick={handleReset}
className="flex-1 h-9"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
onClick={handleFilter}
className="flex-[2] button-filled-primary h-9"
>
<Search className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableHead className="w-[180px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.data.length > 0 ? (
orders.data.map((order) => (
<TableRow key={order.id}>
<TableCell className="font-medium text-primary-main">
<Link href={route('delivery-notes.show', order.id)}>
{order.doc_no}
</Link>
</TableCell>
<TableCell>{order.customer_name || '-'}</TableCell>
<TableCell>{order.warehouse_name}</TableCell>
<TableCell>{order.shipping_date}</TableCell>
<TableCell>${Number(order.grand_total).toLocaleString()}</TableCell>
<TableCell>{getStatusBadge(order.status)}</TableCell>
<TableCell>{order.creator_name}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
asChild
>
<Link href={route('delivery-notes.show', order.id)}>
</Link>
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4 flex justify-end">
<Pagination links={orders.links} />
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,236 @@
import { ArrowLeft, Package, Clock, User, CheckCircle2, AlertCircle, Trash2, Edit } from "lucide-react";
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { Badge } from "@/Components/ui/badge";
import { toast } from "sonner";
import ActivityLogSection from "@/Components/ActivityLog/ActivityLogSection";
interface Props {
order: any;
}
export default function ShippingOrderShow({ order }: Props) {
const isDraft = order.status === 'draft';
const isCompleted = order.status === 'completed';
const handlePost = () => {
if (confirm('確定要執行過帳嗎?這將會從倉庫中扣除庫存數量。')) {
router.post(route('delivery-notes.post', order.id), {}, {
onSuccess: () => toast.success('過帳成功'),
onError: (errors: any) => toast.error(errors.error || '過帳失敗')
});
}
};
const handleDelete = () => {
if (confirm('確定要刪除這張出貨單嗎?')) {
router.delete(route('delivery-notes.destroy', order.id));
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <Badge variant="secondary" className="px-3 py-1">稿</Badge>;
case 'completed':
return <Badge className="bg-green-100 text-green-800 px-3 py-1"></Badge>;
case 'cancelled':
return <Badge variant="destructive" className="px-3 py-1"></Badge>;
default:
return <Badge>{status}</Badge>;
}
};
return (
<AuthenticatedLayout breadcrumbs={[
{ label: '供應鏈管理', href: '#' },
{ label: '出貨單管理', href: route('delivery-notes.index') },
{ label: `出貨單詳情 (${order.doc_no})`, isPage: true }
] as any}>
<Head title={`出貨單詳情 - ${order.doc_no}`} />
<div className="container mx-auto p-6 max-w-7xl">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
<div>
<Link href={route('delivery-notes.index')}>
<Button variant="outline" size="sm" className="gap-2 mb-4">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{order.doc_no}</h1>
{getStatusBadge(order.status)}
</div>
<p className="text-gray-500 mt-1">
: {new Date(order.created_at).toLocaleString()} | : {order.creator_name}
</p>
</div>
<div className="flex items-center gap-2">
{isDraft && (
<>
<Button variant="outline" className="gap-2" asChild>
<Link href={route('delivery-notes.edit', order.id)}>
<Edit className="h-4 w-4" />
</Link>
</Button>
<Button variant="destructive" className="gap-2" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
<Button className="button-filled-primary gap-2" onClick={handlePost}>
<CheckCircle2 className="h-4 w-4" />
</Button>
</>
)}
{isCompleted && (
<div className="flex items-center gap-2 text-green-600 font-medium bg-green-50 px-4 py-2 rounded-lg border border-green-200">
<CheckCircle2 className="h-5 w-5" />
{new Date(order.posted_at).toLocaleString()}
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* 基本資訊 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-lg font-bold mb-6 flex items-center gap-2 border-b pb-4">
<Info className="h-5 w-5 text-primary-main" />
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-6">
<div>
<label className="text-sm text-gray-500 block"></label>
<div className="font-medium text-lg">{order.warehouse_name}</div>
</div>
<div>
<label className="text-sm text-gray-500 block"></label>
<div className="font-medium text-lg">{order.shipping_date}</div>
</div>
<div>
<label className="text-sm text-gray-500 block"></label>
<div className="font-medium text-lg">{order.customer_name || '-'}</div>
</div>
<div>
<label className="text-sm text-gray-500 block"></label>
<div className="font-medium text-lg">{isCompleted ? '已完成 (已扣庫存)' : '草稿 (暫存中)'}</div>
</div>
<div className="md:col-span-2">
<label className="text-sm text-gray-500 block"></label>
<div className="text-gray-700 mt-1 bg-gray-50 p-3 rounded">{order.remarks || '無備註'}</div>
</div>
</div>
</div>
{/* 品項明細 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-lg font-bold flex items-center gap-2">
<Package className="h-5 w-5 text-primary-main" />
</h2>
<Badge variant="outline">{order.items.length} </Badge>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 text-gray-600">
<th className="px-6 py-4 text-left font-semibold"> / </th>
<th className="px-6 py-4 text-left font-semibold"></th>
<th className="px-6 py-4 text-right font-semibold"></th>
<th className="px-6 py-4 text-right font-semibold"></th>
<th className="px-6 py-4 text-right font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{order.items.map((item: any) => (
<tr key={item.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<div className="font-medium text-gray-900">{item.product_name}</div>
<div className="text-xs text-gray-500 font-mono">{item.product_code}</div>
</td>
<td className="px-6 py-4">
<Badge variant="outline" className="font-mono">{item.batch_number || 'N/A'}</Badge>
</td>
<td className="px-6 py-4 text-right">
<span className="font-medium text-gray-900">{parseFloat(item.quantity).toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-1">{item.unit_name}</span>
</td>
<td className="px-6 py-4 text-right text-gray-600">
${parseFloat(item.unit_price).toLocaleString()}
</td>
<td className="px-6 py-4 text-right font-bold text-gray-900">
${parseFloat(item.subtotal).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 活動日誌區塊 */}
<div className="mt-8">
<ActivityLogSection
targetType="App\Modules\Procurement\Models\ShippingOrder"
targetId={order.id}
/>
</div>
</div>
{/* 右側:金額摘要 */}
<div className="space-y-6">
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 sticky top-6">
<h2 className="text-lg font-bold mb-6 flex items-center gap-2 border-b pb-4">
<CalculatorIcon className="h-5 w-5 text-primary-main" />
</h2>
<div className="space-y-4">
<div className="flex justify-between items-center py-2 border-b border-dashed">
<span className="text-gray-500"></span>
<span className="font-medium">${Number(order.total_amount).toLocaleString()}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed">
<span className="text-gray-500"> (5%)</span>
<span className="font-medium">${Number(order.tax_amount).toLocaleString()}</span>
</div>
<div className="flex justify-between items-center py-4">
<span className="font-bold text-lg text-gray-900"></span>
<span className="font-black text-2xl text-primary-main">
${Number(order.grand_total).toLocaleString()}
</span>
</div>
{isDraft && (
<div className="mt-6 p-4 bg-amber-50 rounded-lg border border-amber-200 text-sm text-amber-800 flex gap-3">
<AlertCircle className="h-5 w-5 shrink-0" />
<div>
<p className="font-bold mb-1"></p>
<p>稿</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
function CalculatorIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<rect width="16" height="20" x="4" y="2" rx="2" />
<line x1="8" x2="16" y1="6" y2="6" />
<line x1="16" x2="16" y1="14" y2="18" />
<path d="M16 10h.01" />
<path d="M12 10h.01" />
<path d="M8 10h.01" />
<path d="M12 14h.01" />
<path d="M8 14h.01" />
<path d="M12 18h.01" />
<path d="M8 18h.01" />
</svg>
);
}

View File

@@ -23,14 +23,17 @@ import { Warehouse, InboundItem, InboundReason } from "@/types/warehouse";
import { getCurrentDateTime } from "@/utils/format"; import { getCurrentDateTime } from "@/utils/format";
import { toast } from "sonner"; import { toast } from "sonner";
import { getInventoryBreadcrumbs } from "@/utils/breadcrumb"; import { getInventoryBreadcrumbs } from "@/utils/breadcrumb";
import ScannerInput from "@/Components/Inventory/ScannerInput";
interface Product { interface Product {
id: string; id: string;
name: string; name: string;
code: string; code: string;
barcode?: string;
baseUnit: string; baseUnit: string;
largeUnit?: string; largeUnit?: string;
conversionRate?: number; conversionRate?: number;
costPrice?: number;
} }
interface Batch { interface Batch {
@@ -113,9 +116,101 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
}); });
}, [items, inboundDate]); }, [items, inboundDate]);
// 處理掃碼輸入
const handleScan = async (code: string, mode: 'continuous' | 'single') => {
const cleanCode = code.trim();
// 1. 搜尋商品 (優先比對 Code, Barcode, ID)
let product = products.find(p => p.code === cleanCode || p.barcode === cleanCode || p.id === cleanCode);
// 如果前端找不到,嘗試 API 搜尋 (Fallback)
if (!product) {
try {
// 這裡假設有 API 可以搜尋商品,若沒有則會失敗
// 使用 Product/Index 的搜尋邏輯 (Inertia Props 比較難已 AJAX 取得)
// 替代方案:直接請求 /products?search=CLEAN_CODE&per_page=1
// 加上 header 確認是 JSON 請求
const response = await fetch(`/products?search=${encodeURIComponent(cleanCode)}&per_page=1`, {
headers: {
'X-Requested-With': 'XMLHttpRequest', // 強制 AJAX 識別
}
});
if (response.ok) {
const data = await response.json();
// Inertia 回傳的是 component props 結構,或 partial props
// 根據 ProductController::index回傳 props.products.data
if (data.props && data.props.products && data.props.products.data && data.props.products.data.length > 0) {
const foundProduct = data.props.products.data[0];
// 轉換格式以符合 AddInventory 的 Product 介面
product = {
id: foundProduct.id,
name: foundProduct.name,
code: foundProduct.code,
barcode: foundProduct.barcode,
baseUnit: foundProduct.baseUnit?.name || '個',
largeUnit: foundProduct.largeUnit?.name,
conversionRate: foundProduct.conversionRate,
costPrice: foundProduct.costPrice,
};
}
}
} catch (err) {
console.error("API Search failed", err);
}
}
if (!product) {
toast.error(`找不到商品: ${code}`);
return;
}
// 2. 連續模式:尋找最近一筆相同商品並 +1
if (mode === 'continuous') {
let foundIndex = -1;
// 從後往前搜尋,找到最近加入的那一筆
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].productId === product.id) {
foundIndex = i;
break;
}
}
if (foundIndex !== -1) {
// 更新數量
const newItems = [...items];
const currentQty = newItems[foundIndex].quantity || 0;
newItems[foundIndex] = {
...newItems[foundIndex],
quantity: currentQty + 1
};
setItems(newItems);
toast.success(`${product.name} 數量 +1 (總數: ${currentQty + 1})`);
return;
}
}
// 3. 單筆模式 或 連續模式但尚未加入過:新增一筆
const newItem: InboundItem = {
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
productId: product.id,
productName: product.name,
quantity: 1,
unit: product.baseUnit, // 僅用於顯示當前選擇單位的名稱
baseUnit: product.baseUnit,
largeUnit: product.largeUnit,
conversionRate: product.conversionRate,
selectedUnit: 'base',
batchMode: 'existing', // 預設選擇現有批號 (需要使用者確認/輸入)
originCountry: 'TW',
unit_cost: product.costPrice || 0,
};
setItems(prev => [...prev, newItem]);
toast.success(`已加入 ${product.name}`);
};
// 新增明細行 // 新增明細行
const handleAddItem = () => { const handleAddItem = () => {
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", code: "", baseUnit: "個" }; const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", code: "", baseUnit: "個", costPrice: 0 };
const newItem: InboundItem = { const newItem: InboundItem = {
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
productId: defaultProduct.id, productId: defaultProduct.id,
@@ -128,6 +223,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
selectedUnit: 'base', selectedUnit: 'base',
batchMode: 'existing', // 預設選擇現有批號 batchMode: 'existing', // 預設選擇現有批號
originCountry: 'TW', originCountry: 'TW',
unit_cost: defaultProduct.costPrice || 0,
}; };
setItems([...items, newItem]); setItems([...items, newItem]);
}; };
@@ -162,6 +258,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
batchMode: 'existing', batchMode: 'existing',
inventoryId: undefined, // 清除已選擇的批號 inventoryId: undefined, // 清除已選擇的批號
expiryDate: undefined, expiryDate: undefined,
unit_cost: product.costPrice || 0,
}); });
} }
}; };
@@ -224,7 +321,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
batchMode: item.batchMode, batchMode: item.batchMode,
inventoryId: item.inventoryId, inventoryId: item.inventoryId,
originCountry: item.originCountry, originCountry: item.originCountry,
expiryDate: item.expiryDate expiryDate: item.expiryDate,
unit_cost: item.unit_cost
}; };
}) })
}, { }, {
@@ -384,6 +482,12 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
</Button> </Button>
</div> </div>
{/* 掃碼輸入區 */}
<ScannerInput
onScan={handleScan}
className="bg-gray-50/50"
/>
{errors.items && ( {errors.items && (
<p className="text-sm text-red-500">{errors.items}</p> <p className="text-sm text-red-500">{errors.items}</p>
)} )}
@@ -399,12 +503,13 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
<TableHead className="w-[220px]"> <TableHead className="w-[220px]">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</TableHead> </TableHead>
<TableHead className="w-[100px]">
</TableHead>
<TableHead className="w-[100px]"> <TableHead className="w-[100px]">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</TableHead> </TableHead>
<TableHead className="w-[90px]"></TableHead> <TableHead className="w-[90px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[50px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -479,6 +584,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
)} )}
{item.batchMode === 'new' && ( {item.batchMode === 'new' && (
<>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<div className="flex-1"> <div className="flex-1">
<Input <Input
@@ -496,29 +602,72 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
{getBatchPreview(item.productId, product?.code, item.originCountry || 'TW', inboundDate)} {getBatchPreview(item.productId, product?.code, item.originCountry || 'TW', inboundDate)}
</div> </div>
</div> </div>
{/* 新增效期輸入 (在新增批號模式下) */}
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap">:</span>
<div className="relative flex-1">
<Calendar className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-gray-400 pointer-events-none" />
<Input
type="date"
value={item.expiryDate || ""}
onChange={(e) =>
handleUpdateItem(item.tempId, {
expiryDate: e.target.value,
})
}
className="h-8 pl-8 text-xs border-gray-300 w-full"
/>
</div>
</div>
</>
)} )}
{item.batchMode === 'existing' && item.inventoryId && ( {item.batchMode === 'existing' && item.inventoryId && (
<div className="text-xs text-gray-500 px-2 font-mono"> <div className="flex flax-col gap-1 mt-1">
<div className="text-xs text-gray-500 font-mono">
: {item.expiryDate || '未設定'} : {item.expiryDate || '未設定'}
</div> </div>
</div>
)} )}
</div> </div>
</TableCell> </TableCell>
{/* 單價 */}
<TableCell>
<Input
type="number"
min="0"
step="any"
value={item.unit_cost || 0}
onChange={(e) =>
handleUpdateItem(item.tempId, {
unit_cost: parseFloat(e.target.value) || 0,
})
}
className="border-gray-300 bg-gray-50 text-right"
placeholder="0"
/>
</TableCell>
{/* 數量 */} {/* 數量 */}
<TableCell> <TableCell>
<Input <Input
type="number" type="number"
min="1" min="1"
step="any"
value={item.quantity || ""} value={item.quantity || ""}
onChange={(e) => onChange={(e) =>
handleUpdateItem(item.tempId, { handleUpdateItem(item.tempId, {
quantity: parseFloat(e.target.value) || 0, quantity: parseFloat(e.target.value) || 0,
}) })
} }
className="border-gray-300" className="border-gray-300 text-right"
/> />
{item.selectedUnit === 'large' && item.conversionRate && (
<div className="text-xs text-gray-500 mt-1">
: {convertedQuantity} {item.baseUnit || "個"}
</div>
)}
{errors[`item-${index}-quantity`] && ( {errors[`item-${index}-quantity`] && (
<p className="text-xs text-red-500 mt-1"> <p className="text-xs text-red-500 mt-1">
{errors[`item-${index}-quantity`]} {errors[`item-${index}-quantity`]}
@@ -544,48 +693,20 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
className="border-gray-300" className="border-gray-300"
/> />
) : ( ) : (
<Input <div className="text-sm text-gray-700 font-medium px-3 py-2 bg-gray-50 border border-gray-200 rounded-md">
value={item.baseUnit || "個"} {item.baseUnit || "個"}
disabled </div>
className="bg-gray-50 border-gray-200"
/>
)} )}
</TableCell> </TableCell>
{/* 轉換數量 */}
<TableCell>
<div className="flex items-center text-gray-700 font-medium bg-gray-50 px-3 py-2 rounded-md border border-gray-200">
<span>{convertedQuantity}</span>
<span className="ml-1 text-gray-500 text-sm">{item.baseUnit || "個"}</span>
</div>
</TableCell>
{/* 效期 */}
<TableCell>
<div className="relative">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={item.expiryDate || ""}
onChange={(e) =>
handleUpdateItem(item.tempId, {
expiryDate: e.target.value,
})
}
disabled={item.batchMode === 'existing'}
className={`border-gray-300 pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`}
/>
</div>
</TableCell>
{/* 刪除按鈕 */} {/* 刪除按鈕 */}
<TableCell> <TableCell>
<Button <Button
type="button" type="button"
variant="ghost" variant="outline"
size="icon" size="icon"
onClick={() => handleRemoveItem(item.tempId)} onClick={() => handleRemoveItem(item.tempId)}
className="hover:bg-red-50 hover:text-red-600 h-8 w-8" className="button-outlined-error h-8 w-8"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>

View File

@@ -169,7 +169,7 @@ export default function EditInventory({ warehouse, inventory, transactions = []
id="quantity" id="quantity"
type="number" type="number"
min="0" min="0"
step="0.01" step="any"
value={data.quantity} value={data.quantity}
onChange={(e) => onChange={(e) =>
setData("quantity", parseFloat(e.target.value) || 0) setData("quantity", parseFloat(e.target.value) || 0)

View File

@@ -101,16 +101,7 @@ export default function WarehouseInventoryPage({
}); });
}; };
const handleAdjust = (batchId: string, data: { operation: string; quantity: number; reason: string }) => {
router.put(route("warehouses.inventory.update", { warehouse: warehouse.id, inventoryId: batchId }), data, {
onSuccess: () => {
toast.success("庫存已更新");
},
onError: () => {
toast.error("庫存更新失敗");
}
});
};
return ( return (
<AuthenticatedLayout breadcrumbs={getInventoryBreadcrumbs(warehouse.id, warehouse.name)}> <AuthenticatedLayout breadcrumbs={getInventoryBreadcrumbs(warehouse.id, warehouse.name)}>
@@ -195,7 +186,6 @@ export default function WarehouseInventoryPage({
inventories={filteredInventories} inventories={filteredInventories}
onView={handleView} onView={handleView}
onDelete={confirmDelete} onDelete={confirmDelete}
onAdjust={handleAdjust}
onViewProduct={handleViewProduct} onViewProduct={handleViewProduct}
/> />
</div> </div>