Compare commits
11 Commits
16967fc25d
...
3ce96537b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ce96537b3 | |||
| 04f3891275 | |||
| 4299e985e9 | |||
| 2eb136d280 | |||
| 88415505fb | |||
| 702af0a259 | |||
| f4f597e96d | |||
| a8b88b3375 | |||
| 95fdec8a06 | |||
| 4ba85ce446 | |||
| a0c450d229 |
@@ -1,158 +1,111 @@
|
||||
---
|
||||
name: 操作紀錄實作規範
|
||||
description: 規範系統內 Activity Log 的實作標準,包含後端資料過濾、快照策略、與前端顯示邏輯。
|
||||
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。
|
||||
---
|
||||
|
||||
# 操作紀錄實作規範
|
||||
# 操作紀錄實作規範 (Activity Logging Skill)
|
||||
|
||||
本文件說明如何在開發新功能時,依據系統規範實作 `spatie/laravel-activitylog` 操作紀錄,確保資料儲存效率與前端顯示一致性。
|
||||
本文件定義了 Star ERP 系統中操作紀錄的最高實作標準,旨在確保每筆日誌都具有「高度可讀性」與「單一性」。
|
||||
|
||||
## 1. 後端實作標準 (Backend)
|
||||
---
|
||||
|
||||
所有 Model 之操作紀錄應遵循「僅儲存變動資料」與「保留關鍵快照」兩大原則。
|
||||
## 1. 後端實作核心 (Backend)
|
||||
|
||||
### 1.1 啟用 Activity Log
|
||||
|
||||
在 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` (推薦)**
|
||||
### 1.1 全域 ID 轉名稱邏輯 (Global ID Resolution)
|
||||
為了讓管理者能直覺看懂日誌,所有的 ID(如 `warehouse_id`, `created_by`)在記錄時都應自動解析為名稱。此邏輯應統一在 Model 的 `tapActivity` 中實作。
|
||||
|
||||
#### 關鍵實作參考:
|
||||
```php
|
||||
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'] ?? [];
|
||||
|
||||
// 保存關鍵關聯名稱 (避免關聯資料刪除後 ID 失效)
|
||||
$snapshot['category_name'] = $this->category ? $this->category->name : null;
|
||||
$snapshot['po_number'] = $this->code; // 儲存單號
|
||||
|
||||
// 保存自身名稱 (Context)
|
||||
$snapshot['name'] = $this->name;
|
||||
|
||||
$snapshot['doc_no'] = $this->doc_no;
|
||||
$snapshot['warehouse_name'] = $this->warehouse?->name;
|
||||
$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;
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 顯示名稱映射 (UI Mapping)
|
||||
### 1.2 複雜操作的日誌合併 (Log Consolidation)
|
||||
當一個操作同時涉及「多個品項異動」與「單據狀態變更」時,**嚴禁**產生多筆重複日誌。
|
||||
|
||||
### 2.1 對象名稱映射 (Mapping)
|
||||
|
||||
需在 `ActivityLogController.php` 中設定 Model 與中文名稱的對應,讓前端列表能顯示中文對象(如「公共事業費」而非 `UtilityFee`)。
|
||||
|
||||
**位置**: `app/Http/Controllers/Admin/ActivityLogController.php`
|
||||
* **策略**:在 Service 層手動發送主日誌,並使用 `saveQuietly()` 更新單據屬性以抑止 Trait 的自動日誌。
|
||||
* **格式**:主日誌應包含 `items_diff` (品項差異) 與 `attributes/old` (單據狀態變更)。
|
||||
|
||||
```php
|
||||
protected function getSubjectMap()
|
||||
{
|
||||
return [
|
||||
'App\Modules\Inventory\Models\Product' => '商品',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
|
||||
];
|
||||
}
|
||||
// Service 中的實作方式
|
||||
DB::transaction(function () use ($doc, $items) {
|
||||
// 1. 更新品項 (記錄變動細節)
|
||||
$updatedItems = $this->getUpdatedItems($doc, $items);
|
||||
|
||||
// 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
|
||||
const fieldLabels: Record<string, string> = {
|
||||
// ... 既有欄位
|
||||
'transaction_date': '費用日期',
|
||||
'category': '費用類別',
|
||||
'amount': '金額',
|
||||
warehouse_id: '倉庫', // ❌ 禁用「倉庫 ID」
|
||||
created_by: '建立者', // ❌ 禁用「建立者 ID」
|
||||
completed_by: '完成者',
|
||||
status: '狀態',
|
||||
};
|
||||
```
|
||||
|
||||
## 3. 前端顯示邏輯 (Frontend)
|
||||
### 2.2 特殊結構顯示
|
||||
* **品項異動**:前端應能渲染 `items_diff` 結構,以「品項名稱 + 數值變動」的方式呈現表格(已在 `ActivityDetailDialog` 實作)。
|
||||
|
||||
### 3.1 列表描述生成 (Description Generation)
|
||||
---
|
||||
|
||||
前端 `LogTable.tsx` 會依據 `properties.snapshot` 中的欄位自動組建描述(例如:「Admin 新增 電話費 公共事業費」)。
|
||||
## 3. 開發檢核清單 (Checklist)
|
||||
|
||||
若您的 Model 使用了特殊的識別欄位(例如 `category`),**必須**將其加入 `nameParams` 陣列中。
|
||||
|
||||
**位置**: `resources/js/Components/ActivityLog/LogTable.tsx`
|
||||
|
||||
```typescript
|
||||
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`?
|
||||
- [ ] **Model**: `tapActivity` 是否已處理 Collection 快照?
|
||||
- [ ] **Model**: 是否已實作全域 ID 至名稱的自動解析?
|
||||
- [ ] **Service**: 是否使用 `saveQuietly()` 避免產生重複的「單據已更新」日誌?
|
||||
- [ ] **UI**: `fieldLabels` 是否已移除所有「ID」字樣?
|
||||
- [ ] **UI**: 若有品項異動,是否已正確格式化傳入 `items_diff`?
|
||||
|
||||
@@ -796,7 +796,42 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
|
||||
```tsx
|
||||
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">
|
||||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
|
||||
@@ -28,6 +28,9 @@ class ActivityLogController extends Controller
|
||||
'App\Modules\Production\Models\Recipe' => '生產配方',
|
||||
'App\Modules\Production\Models\RecipeItem' => '配方品項',
|
||||
'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)
|
||||
->withQueryString()
|
||||
->through(function ($activity) {
|
||||
$subjectMap = $this->getSubjectMap();
|
||||
|
||||
|
||||
@@ -187,13 +187,13 @@ class RoleController extends Controller
|
||||
'vendors' => '廠商資料管理',
|
||||
'purchase_orders' => '採購單管理',
|
||||
'goods_receipts' => '進貨單管理',
|
||||
'production_orders' => '生產工單管理',
|
||||
'recipes' => '配方管理',
|
||||
'production_orders' => '生產工單管理',
|
||||
'utility_fees' => '公共事業費管理',
|
||||
'accounting' => '會計報表',
|
||||
'users' => '使用者管理',
|
||||
'roles' => '角色與權限',
|
||||
'system' => '系統管理',
|
||||
'utility_fees' => '公共事業費管理',
|
||||
'accounting' => '會計報表',
|
||||
];
|
||||
|
||||
$result = [];
|
||||
|
||||
@@ -106,6 +106,16 @@ interface InventoryServiceInterface
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -69,6 +69,12 @@ class AdjustDocController extends Controller
|
||||
// 模式 1: 從盤點單建立
|
||||
if ($request->filled('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()) {
|
||||
@@ -76,6 +82,7 @@ class AdjustDocController extends Controller
|
||||
}
|
||||
|
||||
$doc = $this->adjustService->createFromCountDoc($countDoc, auth()->id());
|
||||
|
||||
return redirect()->route('inventory.adjust.show', [$doc->id])
|
||||
->with('success', '已從盤點單生成盤調單');
|
||||
}
|
||||
@@ -127,10 +134,63 @@ class AdjustDocController extends Controller
|
||||
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)
|
||||
{
|
||||
$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 = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
@@ -143,13 +203,15 @@ class AdjustDocController extends Controller
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'count_doc_id' => $doc->count_doc_id ? (string)$doc->count_doc_id : null,
|
||||
'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 [
|
||||
'id' => (string) $item->id,
|
||||
'product_id' => (string) $item->product_id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'qty_before' => (float) $item->qty_before,
|
||||
'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)
|
||||
{
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
|
||||
}
|
||||
|
||||
|
||||
|
||||
$doc->items()->delete();
|
||||
$doc->delete();
|
||||
|
||||
|
||||
@@ -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])
|
||||
->with('success', '已建立盤點單並完成庫存快照');
|
||||
@@ -94,6 +94,16 @@ class CountDocController extends Controller
|
||||
{
|
||||
$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 = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
@@ -103,12 +113,16 @@ class CountDocController extends Controller
|
||||
'remarks' => $doc->remarks,
|
||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null,
|
||||
'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 [
|
||||
'id' => (string) $item->id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, // 新增效期
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'system_qty' => (float) $item->system_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']);
|
||||
}
|
||||
|
||||
// 如果是按了 "完成盤點"
|
||||
if ($request->input('action') === 'complete') {
|
||||
$this->countService->complete($doc, auth()->id());
|
||||
// 重新讀取以獲取最新狀態
|
||||
$doc->refresh();
|
||||
|
||||
if ($doc->status === 'completed') {
|
||||
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)
|
||||
{
|
||||
if ($doc->status !== 'completed') {
|
||||
return redirect()->back()->with('error', '只有已核准的盤點單可以取消核准');
|
||||
// 權限檢查 (通常僅允許有權限者執行,例如 inventory.adjust)
|
||||
// 注意:前端已經用 <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([
|
||||
'status' => 'counting', // Revert to counting (draft)
|
||||
'completed_at' => null,
|
||||
'completed_by' => null,
|
||||
'status' => 'counting', // 回復為盤點中
|
||||
'completed_at' => null, // 清除完成時間
|
||||
'completed_by' => null, // 清除完成者
|
||||
]);
|
||||
|
||||
return redirect()->route('inventory.count.show', [$doc->id])
|
||||
->with('success', '已取消核准,單據回復為盤點中狀態');
|
||||
return redirect()->back()->with('success', '已重新開啟盤點,單據回復為盤點中狀態');
|
||||
}
|
||||
|
||||
public function destroy(InventoryCountDoc $doc)
|
||||
@@ -206,6 +226,8 @@ class CountDocController extends Controller
|
||||
return redirect()->back()->with('error', '已完成的盤點單無法刪除');
|
||||
}
|
||||
|
||||
// Activity Log handled by Model Trait
|
||||
|
||||
$doc->items()->delete();
|
||||
$doc->delete();
|
||||
|
||||
|
||||
@@ -131,16 +131,18 @@ class InventoryController extends Controller
|
||||
{
|
||||
// ... (unchanged) ...
|
||||
$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()
|
||||
->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
'baseUnit' => $product->baseUnit?->name ?? '個',
|
||||
'largeUnit' => $product->largeUnit?->name, // 可能為 null
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'costPrice' => (float) $product->cost_price,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -96,6 +96,10 @@ class ProductController extends Controller
|
||||
] : null,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'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',
|
||||
'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',
|
||||
'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.max' => '商品代號最多 8 碼',
|
||||
@@ -142,6 +151,14 @@ class ProductController extends Controller
|
||||
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||
'conversion_rate.numeric' => '換算率必須為數字',
|
||||
'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);
|
||||
@@ -165,7 +182,12 @@ class ProductController extends Controller
|
||||
'large_unit_id' => 'nullable|exists:units,id',
|
||||
'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',
|
||||
'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.max' => '商品代號最多 8 碼',
|
||||
@@ -181,6 +203,14 @@ class ProductController extends Controller
|
||||
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||
'conversion_rate.numeric' => '換算率必須為數字',
|
||||
'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);
|
||||
|
||||
@@ -82,19 +82,10 @@ class TransferOrderController extends Controller
|
||||
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) {
|
||||
try {
|
||||
$this->transferService->post($order, auth()->id());
|
||||
|
||||
return redirect()->back()->with('success', '撥補成功,庫存已更新');
|
||||
} catch (\Exception $e) {
|
||||
// 如果過帳失敗,雖然單據已建立,但應回報錯誤
|
||||
@@ -134,9 +125,10 @@ class TransferOrderController extends Controller
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $stock && $stock->expiry_date ? $stock->expiry_date->format('Y-m-d') : null,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'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,
|
||||
];
|
||||
}),
|
||||
@@ -153,6 +145,34 @@ class TransferOrderController extends Controller
|
||||
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') {
|
||||
try {
|
||||
$this->transferService->post($order, auth()->id());
|
||||
@@ -163,21 +183,7 @@ class TransferOrderController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$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',
|
||||
]);
|
||||
|
||||
if ($request->has('items')) {
|
||||
$this->transferService->updateItems($order, $validated['items']);
|
||||
}
|
||||
|
||||
$order->update($request->only(['remarks']));
|
||||
|
||||
return redirect()->back()->with('success', '儲存成功');
|
||||
return redirect()->back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function destroy(InventoryTransferOrder $order)
|
||||
@@ -185,6 +191,7 @@ class TransferOrderController extends Controller
|
||||
if ($order->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
|
||||
}
|
||||
|
||||
$order->items()->delete();
|
||||
$order->delete();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace App\Modules\Inventory\Exports;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
// use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
|
||||
class ProductTemplateExport implements WithHeadings, WithColumnFormatting
|
||||
@@ -22,6 +22,10 @@ class ProductTemplateExport implements WithHeadings, WithColumnFormatting
|
||||
'基本單位',
|
||||
'大單位',
|
||||
'換算率',
|
||||
'成本價',
|
||||
'售價',
|
||||
'會員價',
|
||||
'批發價',
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
||||
'large_unit_id' => $largeUnitId,
|
||||
'conversion_rate' => $row['換算率'] ?? 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'],
|
||||
'售價' => ['nullable', 'numeric', 'min:0'],
|
||||
'會員價' => ['nullable', 'numeric', 'min:0'],
|
||||
'批發價' => ['nullable', 'numeric', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class InventoryAdjustDoc extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
@@ -36,7 +39,7 @@ class InventoryAdjustDoc extends Model
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'ADJ' . $today;
|
||||
$prefix = 'ADJ-' . $today . '-';
|
||||
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
@@ -78,4 +81,64 @@ class InventoryAdjustDoc extends Model
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class InventoryCountDoc extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
@@ -36,7 +39,7 @@ class InventoryCountDoc extends Model
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'CNT' . $today;
|
||||
$prefix = 'CNT-' . $today . '-';
|
||||
|
||||
// 查詢當天編號最大的單據
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
@@ -75,4 +78,64 @@ class InventoryCountDoc extends Model
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class InventoryTransferItem extends Model
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'quantity',
|
||||
'snapshot_quantity',
|
||||
'notes',
|
||||
];
|
||||
|
||||
|
||||
@@ -1,16 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
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 = [
|
||||
'doc_no',
|
||||
@@ -35,7 +125,7 @@ class InventoryTransferOrder extends Model
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'TRF' . $today;
|
||||
$prefix = 'TRF-' . $today . '-';
|
||||
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
|
||||
@@ -27,6 +27,10 @@ class Product extends Model
|
||||
'conversion_rate',
|
||||
'purchase_unit_id',
|
||||
'location',
|
||||
'cost_price',
|
||||
'price',
|
||||
'member_price',
|
||||
'wholesale_price',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
||||
@@ -60,6 +60,21 @@ class AdjustService
|
||||
public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void
|
||||
{
|
||||
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();
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
@@ -71,13 +86,60 @@ class AdjustService
|
||||
|
||||
$qtyBefore = $inventory ? $inventory->quantity : 0;
|
||||
|
||||
$doc->items()->create([
|
||||
$newItem = $doc->items()->create([
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'qty_before' => $qtyBefore,
|
||||
'adjust_qty' => $data['adjust_qty'],
|
||||
'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
|
||||
{
|
||||
DB::transaction(function () use ($doc, $userId) {
|
||||
$oldStatus = $doc->status;
|
||||
|
||||
foreach ($doc->items as $item) {
|
||||
if ($item->adjust_qty == 0) continue;
|
||||
|
||||
// 找尋或建立 Inventory
|
||||
// 若是減少庫存,必須確保 Inventory 存在 (且理論上不能變負? 視策略而定,這裡假設允許變負或由 InventoryService 控管)
|
||||
// 若是增加庫存,若不存在需建立
|
||||
|
||||
$inventory = Inventory::firstOrNew([
|
||||
'warehouse_id' => $doc->warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
@@ -103,7 +163,6 @@ class AdjustService
|
||||
|
||||
// 如果是新建立的 object (id 為空),需要初始化 default
|
||||
if (!$inventory->exists) {
|
||||
// 繼承 Product 成本或預設 0 (簡化處理)
|
||||
$inventory->unit_cost = $item->product->cost ?? 0;
|
||||
$inventory->quantity = 0;
|
||||
}
|
||||
@@ -112,7 +171,6 @@ class AdjustService
|
||||
$newQty = $oldQty + $item->adjust_qty;
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
// 用最新的數量 * 單位成本 (簡化成本計算,不採用移動加權)
|
||||
$inventory->total_value = $newQty * $inventory->unit_cost;
|
||||
$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',
|
||||
'posted_at' => now(),
|
||||
'posted_at' => $doc->posted_at->format('Y-m-d H:i:s'),
|
||||
'posted_by' => $userId,
|
||||
]);
|
||||
],
|
||||
'old' => [
|
||||
'status' => $oldStatus,
|
||||
'posted_at' => null,
|
||||
'posted_by' => null,
|
||||
],
|
||||
'items_diff' => [
|
||||
'updated' => $itemsSnapshot,
|
||||
]
|
||||
])
|
||||
->log('posted');
|
||||
|
||||
// 4. 若關聯盤點單,連動更新盤點單狀態
|
||||
if ($doc->count_doc_id) {
|
||||
InventoryCountDoc::where('id', $doc->count_doc_id)->update([
|
||||
'status' => 'adjusted'
|
||||
]);
|
||||
$countDoc = InventoryCountDoc::find($doc->count_doc_id);
|
||||
if ($countDoc) {
|
||||
$countDoc->status = 'adjusted';
|
||||
$countDoc->saveQuietly(); // 盤點單也靜默更新
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -152,9 +246,20 @@ class AdjustService
|
||||
if ($doc->status !== 'draft') {
|
||||
throw new \Exception('只能作廢草稿狀態的單據');
|
||||
}
|
||||
$doc->update([
|
||||
'status' => 'voided',
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
|
||||
$oldStatus = $doc->status;
|
||||
$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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ class CountService
|
||||
return DB::transaction(function () use ($warehouseId, $remarks, $userId) {
|
||||
$doc = InventoryCountDoc::create([
|
||||
'warehouse_id' => $warehouseId,
|
||||
'status' => 'draft',
|
||||
'status' => 'counting',
|
||||
'snapshot_date' => now(),
|
||||
'remarks' => $remarks,
|
||||
'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 (如果有)
|
||||
$doc->items()->delete();
|
||||
|
||||
@@ -62,10 +63,12 @@ class CountService
|
||||
InventoryCountItem::insert($items);
|
||||
}
|
||||
|
||||
if ($updateDoc) {
|
||||
$doc->update([
|
||||
'status' => 'counting',
|
||||
'snapshot_date' => now(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,19 +94,115 @@ class CountService
|
||||
public function updateCount(InventoryCountDoc $doc, array $itemsData): void
|
||||
{
|
||||
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) {
|
||||
$item = $doc->items()->find($data['id']);
|
||||
$item = $doc->items()->with('product')->find($data['id']);
|
||||
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'];
|
||||
$diff = is_numeric($countedQty) ? ($countedQty - $item->system_qty) : 0;
|
||||
|
||||
$item->update([
|
||||
'counted_qty' => $countedQty,
|
||||
'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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ class GoodsReceiptService
|
||||
|
||||
private function generateCode(string $date)
|
||||
{
|
||||
// Format: GR + YYYYMMDD + NNN
|
||||
$prefix = 'GR' . date('Ymd', strtotime($date));
|
||||
// Format: GR-YYYYMMDD-NN
|
||||
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
|
||||
|
||||
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
|
||||
->orderBy('id', 'desc')
|
||||
@@ -99,11 +99,11 @@ class GoodsReceiptService
|
||||
->first();
|
||||
|
||||
if ($last) {
|
||||
$seq = intval(substr($last->code, -3)) + 1;
|
||||
$seq = intval(substr($last->code, -2)) + 1;
|
||||
} else {
|
||||
$seq = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad($seq, 3, '0', STR_PAD_LEFT);
|
||||
return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
// 庫存總表 join 安全庫存表,計算低庫存
|
||||
|
||||
@@ -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();
|
||||
|
||||
$newItemsKeys = [];
|
||||
|
||||
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'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'quantity' => $data['quantity'],
|
||||
'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
|
||||
{
|
||||
// [IMPORTANT] 強制重新載入品項,因為在 Controller 中可能剛執行過 updateItems,導致記憶體中快取的 items 是舊的或空的
|
||||
$order->load('items.product');
|
||||
|
||||
DB::transaction(function () use ($order, $userId) {
|
||||
$fromWarehouse = $order->fromWarehouse;
|
||||
$toWarehouse = $order->toWarehouse;
|
||||
@@ -71,6 +156,9 @@ class TransferService
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $item->quantity;
|
||||
|
||||
// 儲存庫存快照
|
||||
$item->update(['snapshot_quantity' => $oldSourceQty]);
|
||||
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
// 更新總值 (假設成本不變)
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
||||
@@ -131,11 +219,25 @@ class TransferService
|
||||
]);
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'status' => 'completed',
|
||||
'posted_at' => now(),
|
||||
'posted_by' => $userId,
|
||||
]);
|
||||
// 準備品項快照供日誌使用
|
||||
$itemsSnapshot = $order->items->map(function($item) {
|
||||
return [
|
||||
'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(); // 觸發自動日誌
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -187,20 +187,20 @@ class PurchaseOrderController extends Controller
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// 生成單號:POYYYYMMDD001
|
||||
// 生成單號:PO-YYYYMMDD-01
|
||||
$today = now()->format('Ymd');
|
||||
$prefix = 'PO' . $today;
|
||||
$prefix = 'PO-' . $today . '-';
|
||||
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
|
||||
->lockForUpdate() // 鎖定以避免並發衝突
|
||||
->orderBy('code', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastOrder) {
|
||||
// 取得最後 3 碼序號並加 1
|
||||
$lastSequence = intval(substr($lastOrder->code, -3));
|
||||
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
|
||||
// 取得最後 2 碼序號並加 1
|
||||
$lastSequence = intval(substr($lastOrder->code, -2));
|
||||
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$sequence = '001';
|
||||
$sequence = '01';
|
||||
}
|
||||
$code = $prefix . $sequence;
|
||||
|
||||
|
||||
133
app/Modules/Procurement/Controllers/ShippingOrderController.php
Normal file
133
app/Modules/Procurement/Controllers/ShippingOrderController.php
Normal 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', '出貨單已刪除');
|
||||
}
|
||||
}
|
||||
89
app/Modules/Procurement/Models/ShippingOrder.php
Normal file
89
app/Modules/Procurement/Models/ShippingOrder.php
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
35
app/Modules/Procurement/Models/ShippingOrderItem.php
Normal file
35
app/Modules/Procurement/Models/ShippingOrderItem.php
Normal 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)
|
||||
}
|
||||
@@ -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::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');
|
||||
});
|
||||
});
|
||||
|
||||
118
app/Modules/Procurement/Services/ShippingService.php
Normal file
118
app/Modules/Procurement/Services/ShippingService.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,33 @@ class ProductionOrderController extends Controller
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 編輯生產單
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,10 @@ Route::middleware('auth')->group(function () {
|
||||
->middleware('permission:production_orders.create')
|
||||
->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'])
|
||||
->name('api.production.recipes.latest-by-product');
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Middleware\TrustProxies;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Spatie\Permission\Exceptions\UnauthorizedException;
|
||||
use Inertia\Inertia;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -37,14 +38,24 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
// 處理 Spatie Permission 的 UnauthorizedException
|
||||
$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) {
|
||||
if ($e->getStatusCode() === 403) {
|
||||
return Inertia::render('Error/403')->toResponse(request())->setStatusCode(403);
|
||||
}
|
||||
$status = $e->getStatusCode();
|
||||
return Inertia::render('Error/Index', ['status' => $status])
|
||||
->toResponse(request())
|
||||
->setStatusCode($status);
|
||||
});
|
||||
})->create();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -30,7 +30,7 @@ class PermissionSeeder extends Seeder
|
||||
'purchase_orders.create',
|
||||
'purchase_orders.edit',
|
||||
'purchase_orders.delete',
|
||||
'purchase_orders.publish',
|
||||
|
||||
|
||||
// 庫存管理
|
||||
'inventory.view',
|
||||
@@ -61,6 +61,12 @@ class PermissionSeeder extends Seeder
|
||||
'goods_receipts.edit',
|
||||
'goods_receipts.delete',
|
||||
|
||||
// 出貨單管理 (Delivery Notes / Shipping Orders)
|
||||
'delivery_notes.view',
|
||||
'delivery_notes.create',
|
||||
'delivery_notes.edit',
|
||||
'delivery_notes.delete',
|
||||
|
||||
// 生產工單管理
|
||||
'production_orders.view',
|
||||
'production_orders.create',
|
||||
@@ -132,12 +138,13 @@ class PermissionSeeder extends Seeder
|
||||
$admin->givePermissionTo([
|
||||
'products.view', 'products.create', 'products.edit', 'products.delete',
|
||||
'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_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.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',
|
||||
'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',
|
||||
'recipes.view', 'recipes.create', 'recipes.edit', 'recipes.delete',
|
||||
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
|
||||
|
||||
@@ -97,6 +97,7 @@ const fieldLabels: Record<string, string> = {
|
||||
source_purchase_order_id: '來源採購單',
|
||||
quality_status: '品質狀態',
|
||||
quality_remark: '品質備註',
|
||||
purchase_order_id: '來源採購單',
|
||||
// 採購單欄位
|
||||
po_number: '採購單號',
|
||||
vendor_id: '廠商',
|
||||
@@ -105,6 +106,7 @@ const fieldLabels: Record<string, string> = {
|
||||
user_id: '建單人員',
|
||||
total_amount: '小計',
|
||||
expected_delivery_date: '預計到貨日',
|
||||
order_date: '下單日期',
|
||||
status: '狀態',
|
||||
tax_amount: '稅額',
|
||||
grand_total: '總計',
|
||||
@@ -129,6 +131,24 @@ const fieldLabels: Record<string, string> = {
|
||||
recipe_id: '生產配方',
|
||||
recipe_name: '配方名稱',
|
||||
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: '已收貨',
|
||||
cancelled: '已取消',
|
||||
completed: '已完成',
|
||||
closed: '已結案',
|
||||
partial: '部分收貨',
|
||||
// 庫存單據狀態
|
||||
counting: '盤點中',
|
||||
posted: '已過帳',
|
||||
no_adjust: '無需盤調',
|
||||
adjusted: '已盤調',
|
||||
// 生產工單狀態
|
||||
planned: '已計畫',
|
||||
in_progress: '生產中',
|
||||
// 調撥單狀態
|
||||
voided: '已作廢',
|
||||
// completed 已定義
|
||||
};
|
||||
|
||||
@@ -154,6 +183,13 @@ const qualityStatusMap: Record<string, string> = {
|
||||
rejected: '瑕疵/拒收',
|
||||
};
|
||||
|
||||
// 入庫類型翻譯對照表
|
||||
const typeMap: Record<string, string> = {
|
||||
standard: '採購進貨',
|
||||
miscellaneous: '雜項入庫',
|
||||
other: '其他入庫',
|
||||
};
|
||||
|
||||
export default function ActivityDetailDialog({ open, onOpenChange, activity }: Props) {
|
||||
if (!activity) return null;
|
||||
|
||||
@@ -166,7 +202,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
|
||||
// 自訂欄位排序順序
|
||||
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',
|
||||
'total_amount', 'tax_amount', 'grand_total' // 確保金額的特定順序
|
||||
];
|
||||
@@ -189,12 +225,21 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// 檢查鍵是否為快照名稱欄位的輔助函式
|
||||
// 檢查鍵是否為快照名稱欄位或輔助名稱欄位的輔助函式
|
||||
const isSnapshotField = (key: string) => {
|
||||
return [
|
||||
// 隱藏快照欄位
|
||||
const snapshotFields = [
|
||||
'category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name',
|
||||
'warehouse_name', 'user_name'
|
||||
].includes(key);
|
||||
'warehouse_name', 'user_name', 'from_warehouse_name', 'to_warehouse_name',
|
||||
'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) => {
|
||||
@@ -234,12 +279,36 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
return qualityStatusMap[value];
|
||||
}
|
||||
|
||||
// 處理入庫類型
|
||||
if (key === 'type' && typeof value === 'string' && typeMap[value]) {
|
||||
return typeMap[value];
|
||||
}
|
||||
|
||||
// 處理日期欄位 (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)
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -270,7 +339,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
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) {
|
||||
if (snapshot[param]) return snapshot[param];
|
||||
if (attributes[param]) return attributes[param];
|
||||
@@ -285,7 +354,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<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]">
|
||||
{activity.event === 'created' ? (
|
||||
<div className="border rounded-md overflow-hidden bg-white shadow-sm">
|
||||
<Table>
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50 hover:bg-gray-50/50">
|
||||
<TableHead className="w-[150px]">欄位</TableHead>
|
||||
<TableHead>異動前</TableHead>
|
||||
<TableHead>異動後</TableHead>
|
||||
<TableHead className="w-[140px]">欄位</TableHead>
|
||||
<TableHead className="w-1/2">異動前</TableHead>
|
||||
<TableHead className="w-1/2">異動後</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -340,9 +409,9 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
.filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key))
|
||||
.map((key) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell className="font-medium text-gray-700 w-[120px] shrink-0">{getFieldLabel(key)}</TableCell>
|
||||
<TableCell className="text-gray-500 break-all min-w-[150px]">-</TableCell>
|
||||
<TableCell className="text-gray-900 font-medium break-all min-w-[200px] whitespace-pre-wrap">
|
||||
<TableCell className="font-medium text-gray-700 w-[140px] truncate">{getFieldLabel(key)}</TableCell>
|
||||
<TableCell className="text-gray-500">-</TableCell>
|
||||
<TableCell className="text-gray-900 font-medium break-all whitespace-pre-wrap">
|
||||
{getFormattedValue(key, attributes[key])}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -359,12 +428,12 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden bg-white shadow-sm">
|
||||
<Table>
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableHead className="w-[150px]">欄位</TableHead>
|
||||
<TableHead>異動前</TableHead>
|
||||
<TableHead>異動後</TableHead>
|
||||
<TableHead className="w-[140px]">欄位</TableHead>
|
||||
<TableHead className="w-1/2">異動前</TableHead>
|
||||
<TableHead className="w-1/2">異動後</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -398,11 +467,11 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
|
||||
return (
|
||||
<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="text-gray-500 break-all min-w-[150px] whitespace-pre-wrap">
|
||||
<TableCell className="font-medium text-gray-700 w-[140px] truncate">{getFieldLabel(key)}</TableCell>
|
||||
<TableCell className="text-gray-500 break-all whitespace-pre-wrap">
|
||||
{displayBefore}
|
||||
</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}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -428,17 +497,17 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
</h3>
|
||||
|
||||
<div className="border rounded-md overflow-hidden bg-white shadow-sm">
|
||||
<Table>
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader className="bg-gray-50/50">
|
||||
<TableRow>
|
||||
<TableHead>商品名稱</TableHead>
|
||||
<TableHead className="text-center">異動類型</TableHead>
|
||||
<TableHead>異動詳情 (舊 → 新)</TableHead>
|
||||
<TableHead className="w-1/3">商品名稱</TableHead>
|
||||
<TableHead className="w-[100px] text-center">異動類型</TableHead>
|
||||
<TableHead className="w-1/2">異動詳情 (舊 → 新)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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">
|
||||
<TableCell className="font-medium">{item.product_name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
@@ -446,35 +515,46 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
</TableCell>
|
||||
</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">
|
||||
<TableCell className="font-medium">{item.product_name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">新增</Badge>
|
||||
</TableCell>
|
||||
<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>
|
||||
</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">
|
||||
<TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
@@ -484,7 +564,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
原紀錄: {item.quantity} {item.unit_name}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)) || null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
39
resources/js/Components/ActivityLog/ActivityLog.tsx
Normal file
39
resources/js/Components/ActivityLog/ActivityLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default function LogTable({
|
||||
|
||||
// 嘗試在快照、屬性或舊值中尋找名稱
|
||||
// 優先順序:快照 > 特定名稱欄位 > 通用名稱 > 代碼 > 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 = '';
|
||||
|
||||
// 庫存的特殊處理:顯示 "倉庫 - 商品"
|
||||
|
||||
161
resources/js/Components/Inventory/ScannerInput.tsx
Normal file
161
resources/js/Components/Inventory/ScannerInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,10 @@ export default function ProductDialog({
|
||||
conversion_rate: "",
|
||||
purchase_unit_id: "",
|
||||
location: "",
|
||||
cost_price: "",
|
||||
price: "",
|
||||
member_price: "",
|
||||
wholesale_price: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,6 +69,10 @@ export default function ProductDialog({
|
||||
conversion_rate: product.conversionRate ? product.conversionRate.toString() : "",
|
||||
purchase_unit_id: product.purchaseUnitId?.toString() || "",
|
||||
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 {
|
||||
reset();
|
||||
@@ -235,6 +243,72 @@ export default function ProductDialog({
|
||||
</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">
|
||||
<h3 className="text-lg font-medium border-b pb-2">單位設定</h3>
|
||||
@@ -278,7 +352,7 @@ export default function ProductDialog({
|
||||
<Input
|
||||
id="conversion_rate"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
step="any"
|
||||
value={data.conversion_rate}
|
||||
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}` : ""}
|
||||
|
||||
@@ -110,13 +110,13 @@ export function PurchaseOrderItemsTable({
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
step="any"
|
||||
value={item.quantity === 0 ? "" : Math.floor(item.quantity)}
|
||||
onChange={(e) =>
|
||||
onItemChange?.(index, "quantity", Math.floor(Number(e.target.value)))
|
||||
onItemChange?.(index, "quantity", Number(e.target.value))
|
||||
}
|
||||
disabled={isDisabled}
|
||||
className="text-left w-24"
|
||||
className="text-right w-24"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -189,13 +189,13 @@ export function PurchaseOrderItemsTable({
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
step="any"
|
||||
value={item.subtotal || ""}
|
||||
onChange={(e) =>
|
||||
onItemChange?.(index, "subtotal", Number(e.target.value))
|
||||
}
|
||||
disabled={isDisabled}
|
||||
className={`text-left w-32 ${
|
||||
className={`text-right w-32 ${
|
||||
// 如果有數量但沒有金額,顯示錯誤樣式
|
||||
item.quantity > 0 && (!item.subtotal || item.subtotal <= 0)
|
||||
? "border-red-400 bg-red-50 focus-visible:ring-red-500"
|
||||
|
||||
@@ -78,6 +78,7 @@ export default function EditSafetyStockDialog({
|
||||
id="safetyStock"
|
||||
type="number"
|
||||
min="1"
|
||||
step="any"
|
||||
value={safetyStock}
|
||||
onChange={(e) => setSafetyStock(parseInt(e.target.value) || 0)}
|
||||
placeholder="請輸入安全庫存量"
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function UtilityFeeDialog({
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
step="any"
|
||||
value={data.amount}
|
||||
onChange={(e) => setData("amount", e.target.value)}
|
||||
placeholder="0.00"
|
||||
|
||||
@@ -159,6 +159,8 @@ export default function AddSupplyProductDialog({
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="輸入價格"
|
||||
value={lastPrice}
|
||||
onChange={(e) => setLastPrice(e.target.value)}
|
||||
|
||||
@@ -86,6 +86,8 @@ export default function EditSupplyProductDialog({
|
||||
<Label className="text-muted-foreground text-xs">上次採購單價 / {product.baseUnit || "單位"}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="輸入價格"
|
||||
value={lastPrice}
|
||||
onChange={(e) => setLastPrice(e.target.value)}
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function BatchAdjustmentModal({
|
||||
<Input
|
||||
id="adj-qty"
|
||||
type="number"
|
||||
step="0.01"
|
||||
step="any"
|
||||
min="0"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function InventoryAdjustmentDialog({
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
step="0.01"
|
||||
step="any"
|
||||
value={data.quantity === 0 ? "" : data.quantity}
|
||||
onChange={e => setData("quantity", Number(e.target.value))}
|
||||
placeholder="請輸入數量"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -28,13 +28,12 @@ import {
|
||||
import { GroupedInventory } from "@/types/warehouse";
|
||||
import { formatDate } from "@/utils/format";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import BatchAdjustmentModal from "./BatchAdjustmentModal";
|
||||
|
||||
|
||||
interface InventoryTableProps {
|
||||
inventories: GroupedInventory[];
|
||||
onView: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onAdjust: (batchId: string, data: { operation: string; quantity: number; reason: string }) => void;
|
||||
onViewProduct?: (productId: string) => void;
|
||||
}
|
||||
|
||||
@@ -42,19 +41,12 @@ export default function InventoryTable({
|
||||
inventories,
|
||||
onView,
|
||||
onDelete,
|
||||
onAdjust,
|
||||
onViewProduct,
|
||||
}: InventoryTableProps) {
|
||||
// 每個商品的展開/折疊狀態
|
||||
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) {
|
||||
return (
|
||||
@@ -244,22 +236,7 @@ export default function InventoryTable({
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</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">
|
||||
<Tooltip>
|
||||
<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>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function AddSafetyStockDialog({
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
step="any"
|
||||
value={quantity || ""}
|
||||
onChange={(e) =>
|
||||
updateQuantity(productId, parseFloat(e.target.value) || 0)
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function EditSafetyStockDialog({
|
||||
id="edit-safety"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
step="any"
|
||||
value={safetyStock}
|
||||
onChange={(e) => setSafetyStock(parseFloat(e.target.value) || 0)}
|
||||
className="button-outlined-primary"
|
||||
|
||||
@@ -92,7 +92,7 @@ export function SearchableSelect({
|
||||
<PopoverContent
|
||||
className="p-0 z-[9999]"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
style={{ width: "var(--radix-popover-trigger-width)", minWidth: "12rem" }}
|
||||
>
|
||||
<Command>
|
||||
{shouldShowSearch && (
|
||||
|
||||
@@ -150,13 +150,13 @@ export default function AuthenticatedLayout({
|
||||
route: "/goods-receipts",
|
||||
permission: "goods_receipts.view",
|
||||
},
|
||||
// {
|
||||
// id: "delivery-note-list",
|
||||
// label: "出貨單管理 (開發中)",
|
||||
// icon: <Package className="h-4 w-4" />,
|
||||
// // route: "/delivery-notes",
|
||||
// permission: "delivery_notes.view",
|
||||
// },
|
||||
{
|
||||
id: "delivery-note-list",
|
||||
label: "出貨單管理 (功能製作中)",
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
route: "/delivery-notes",
|
||||
permission: "delivery_notes.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route('activity-logs.index'),
|
||||
{ ...filters, per_page: value },
|
||||
{ ...filters, per_page: value, page: 1 },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,19 +4,8 @@ import { Shield, ArrowLeft, Check } from 'lucide-react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
interface Permission {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GroupedPermission {
|
||||
key: string;
|
||||
name: string;
|
||||
permissions: Permission[];
|
||||
}
|
||||
import PermissionSelector, { GroupedPermission } from './PermissionSelector';
|
||||
|
||||
interface Props {
|
||||
groupedPermissions: GroupedPermission[];
|
||||
@@ -34,56 +23,6 @@ export default function RoleCreate({ groupedPermissions }: Props) {
|
||||
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 (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
@@ -171,52 +110,11 @@ export default function RoleCreate({ groupedPermissions }: Props) {
|
||||
{/* Permissions Matrix */}
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
{groupedPermissions.map((group) => {
|
||||
const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name));
|
||||
|
||||
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)}
|
||||
<PermissionSelector
|
||||
groupedPermissions={groupedPermissions}
|
||||
selectedPermissions={data.permissions}
|
||||
onChange={(permissions) => setData('permissions', permissions)}
|
||||
/>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,19 +4,8 @@ import { Shield, ArrowLeft, Check, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
interface Permission {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GroupedPermission {
|
||||
key: string;
|
||||
name: string;
|
||||
permissions: Permission[];
|
||||
}
|
||||
import PermissionSelector, { GroupedPermission } from './PermissionSelector';
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
@@ -42,71 +31,6 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
|
||||
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 (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
@@ -201,52 +125,11 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
|
||||
{/* Permissions Matrix */}
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
{groupedPermissions.map((group) => {
|
||||
const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name));
|
||||
|
||||
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)}
|
||||
<PermissionSelector
|
||||
groupedPermissions={groupedPermissions}
|
||||
selectedPermissions={data.permissions}
|
||||
onChange={(permissions) => setData('permissions', permissions)}
|
||||
/>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
277
resources/js/Pages/Admin/Role/PermissionSelector.tsx
Normal file
277
resources/js/Pages/Admin/Role/PermissionSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
resources/js/Pages/Common/UnderConstruction.tsx
Normal file
61
resources/js/Pages/Common/UnderConstruction.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
99
resources/js/Pages/Error/Index.tsx
Normal file
99
resources/js/Pages/Error/Index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -48,8 +48,10 @@ interface AdjItem {
|
||||
qty_before: number | string;
|
||||
adjust_qty: number | string;
|
||||
notes: string;
|
||||
expiry_date?: string | null;
|
||||
}
|
||||
|
||||
|
||||
interface AdjDoc {
|
||||
id: string;
|
||||
doc_no: string;
|
||||
@@ -155,6 +157,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
||||
qty_before: inv.quantity || 0,
|
||||
adjust_qty: 0,
|
||||
notes: '',
|
||||
expiry_date: inv.expiry_date,
|
||||
});
|
||||
addedCount++;
|
||||
}
|
||||
@@ -253,7 +256,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
||||
<>
|
||||
<span className="mx-1">|</span>
|
||||
<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"
|
||||
>
|
||||
來源盤點單: {doc.count_doc_no}
|
||||
@@ -409,9 +412,10 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
||||
onCheckedChange={() => toggleSelectAll()}
|
||||
/>
|
||||
</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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -447,9 +451,10 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
||||
onCheckedChange={() => toggleSelect(key)}
|
||||
/>
|
||||
</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="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>
|
||||
</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>
|
||||
</div>
|
||||
</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-right font-medium text-grey-400">
|
||||
{item.qty_before}
|
||||
@@ -542,7 +554,8 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
||||
<div className="flex justify-end pr-2">
|
||||
<Input
|
||||
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}
|
||||
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 && (
|
||||
<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"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="button-outlined-error h-8 w-8"
|
||||
onClick={() => removeItem(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { usePermission } from '@/hooks/usePermission';
|
||||
import { debounce } from "lodash";
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
import Pagination from '@/Components/shared/Pagination';
|
||||
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 [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
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();
|
||||
post(route('inventory.count.store'), {
|
||||
onSuccess: () => {
|
||||
@@ -135,14 +135,16 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <Badge variant="secondary">草稿</Badge>;
|
||||
case 'counting':
|
||||
return <Badge className="bg-blue-500 hover:bg-blue-600">盤點中</Badge>;
|
||||
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':
|
||||
return <Badge className="bg-purple-500 hover:bg-purple-600">已盤調庫存</Badge>;
|
||||
case 'cancelled':
|
||||
@@ -155,7 +157,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '商品與庫存與管理', href: '#' },
|
||||
{ label: '商品與庫存管理', href: '#' },
|
||||
{ label: '庫存盤點', href: route('inventory.count.index'), isPage: true },
|
||||
]}
|
||||
>
|
||||
@@ -287,7 +289,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
docs.data.map((doc, index) => (
|
||||
docs.data.map((doc: any, index: number) => (
|
||||
<TableRow key={doc.id}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{(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">
|
||||
{/* 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 canView = can('inventory_count.view');
|
||||
|
||||
@@ -343,7 +345,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
||||
|
||||
return null;
|
||||
})()}
|
||||
{!['completed', 'adjusted'].includes(doc.status) && (
|
||||
{!['completed', 'no_adjust', 'adjusted'].includes(doc.status) && (
|
||||
<Can permission="inventory_count.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -26,7 +26,19 @@ import {
|
||||
} from "@/Components/ui/alert-dialog"
|
||||
import { Can } from '@/Components/Permission/Can';
|
||||
|
||||
|
||||
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
|
||||
const { data, setData, put, delete: destroy, processing, transform } = useForm({
|
||||
items: doc.items.map((item: any) => ({
|
||||
@@ -63,7 +75,7 @@ export default function Show({ doc }: any) {
|
||||
}
|
||||
|
||||
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 isReadOnly = isCompleted || !canEdit;
|
||||
|
||||
@@ -76,21 +88,28 @@ export default function Show({ doc }: any) {
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ 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 },
|
||||
]}
|
||||
].filter(Boolean) as any}
|
||||
>
|
||||
<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>
|
||||
<Link href={route('inventory.count.index')}>
|
||||
<Link href={backUrl}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回盤點單列表
|
||||
{backLabel}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -102,7 +121,10 @@ export default function Show({ doc }: any) {
|
||||
盤點單: {doc.doc_no}
|
||||
</h1>
|
||||
{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' && (
|
||||
<Badge className="bg-purple-500 hover:bg-purple-600">已盤調庫存</Badge>
|
||||
@@ -127,7 +149,7 @@ export default function Show({ doc }: any) {
|
||||
列印
|
||||
</Button>
|
||||
|
||||
{doc.status === 'completed' && (
|
||||
{['completed', 'no_adjust'].includes(doc.status) && (
|
||||
<Can permission="inventory.adjust">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -138,19 +160,19 @@ export default function Show({ doc }: any) {
|
||||
disabled={processing}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
取消核准
|
||||
重新開啟盤點
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確定要取消核准嗎?</AlertDialogTitle>
|
||||
<AlertDialogTitle>確定要重新開啟盤點嗎?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
單據將回復為「盤點中」狀態,若已產生庫存異動將被撤回。此動作可讓您重新編輯盤點數量。
|
||||
單據將回復為「盤點中」狀態。此動作可讓您重新編輯盤點數量。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<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>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
@@ -184,23 +206,13 @@ export default function Show({ doc }: any) {
|
||||
|
||||
<Can permission="inventory_count.edit">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
className="button-filled-primary"
|
||||
onClick={() => handleSubmit('save')}
|
||||
disabled={processing}
|
||||
>
|
||||
<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>
|
||||
</Can>
|
||||
</div>
|
||||
@@ -264,20 +276,27 @@ export default function Show({ doc }: any) {
|
||||
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
|
||||
<TableCell className="text-right font-medium">{item.system_qty.toFixed(0)}</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-medium">{Number(item.system_qty)}</TableCell>
|
||||
<TableCell className="text-right px-1 py-3">
|
||||
{isReadOnly ? (
|
||||
<span className="font-semibold mr-2">{item.counted_qty}</span>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
step="any"
|
||||
value={formItem.counted_qty ?? ''}
|
||||
onChange={(e) => updateItem(index, 'counted_qty', e.target.value)}
|
||||
onWheel={(e: any) => e.target.blur()}
|
||||
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="盤點..."
|
||||
/>
|
||||
)}
|
||||
@@ -290,7 +309,7 @@ export default function Show({ doc }: any) {
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{formItem.counted_qty !== '' && formItem.counted_qty !== null
|
||||
? diff.toFixed(0)
|
||||
? Number(diff.toFixed(2))
|
||||
: '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
@@ -318,6 +337,7 @@ export default function Show({ doc }: any) {
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</AuthenticatedLayout >
|
||||
|
||||
@@ -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 介面
|
||||
interface PendingPOItem {
|
||||
@@ -207,13 +201,12 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
};
|
||||
|
||||
// Batch management
|
||||
const [batchesCache, setBatchesCache] = useState<Record<string, BatchItem[]>>({});
|
||||
const [nextSequences, setNextSequences] = useState<Record<string, number>>({});
|
||||
|
||||
// Fetch batches and sequence for a product
|
||||
const fetchProductBatches = async (productId: number, country: string = 'TW', dateStr: string = '') => {
|
||||
if (!data.warehouse_id) return;
|
||||
const cacheKey = `${productId}-${data.warehouse_id}`;
|
||||
// const cacheKey = `${productId}-${data.warehouse_id}`; // Unused
|
||||
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
@@ -233,13 +226,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
);
|
||||
|
||||
if (response.data) {
|
||||
// Update existing batches list
|
||||
if (response.data.batches) {
|
||||
setBatchesCache(prev => ({
|
||||
...prev,
|
||||
[cacheKey]: response.data.batches
|
||||
}));
|
||||
}
|
||||
// Remove unused batch cache update
|
||||
|
||||
// Update next sequence for new batch generation
|
||||
if (response.data.nextSequence !== undefined) {
|
||||
@@ -314,7 +301,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<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" />
|
||||
返回進貨單
|
||||
</Button>
|
||||
@@ -645,11 +632,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
step="any"
|
||||
min="0"
|
||||
value={item.quantity_received}
|
||||
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] && (
|
||||
<p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p>
|
||||
|
||||
@@ -173,7 +173,7 @@ export default function Index({ warehouses, orders, filters }: any) {
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '商品與庫存與管理', href: '#' },
|
||||
{ label: '商品與庫存管理', href: '#' },
|
||||
{ label: '庫存調撥', href: route('inventory.transfer.index'), isPage: true },
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -116,6 +116,7 @@ export default function Show({ order }: any) {
|
||||
product_name: inv.product_name,
|
||||
product_code: inv.product_code,
|
||||
batch_number: inv.batch_number,
|
||||
expiry_date: inv.expiry_date,
|
||||
unit: inv.unit_name,
|
||||
quantity: 1, // Default 1
|
||||
max_quantity: inv.quantity, // Max available
|
||||
@@ -154,7 +155,7 @@ export default function Show({ order }: any) {
|
||||
items: items,
|
||||
remarks: remarks,
|
||||
}, {
|
||||
onSuccess: () => toast.success("儲存成功"),
|
||||
onSuccess: () => { },
|
||||
onError: () => toast.error("儲存失敗,請檢查輸入"),
|
||||
});
|
||||
} finally {
|
||||
@@ -168,7 +169,6 @@ export default function Show({ order }: any) {
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsPostDialogOpen(false);
|
||||
toast.success("過帳成功");
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -177,7 +177,6 @@ export default function Show({ order }: any) {
|
||||
router.delete(route('inventory.transfer.destroy', [order.id]), {
|
||||
onSuccess: () => {
|
||||
setDeleteId(null);
|
||||
toast.success("已成功刪除");
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -373,9 +372,10 @@ export default function Show({ order }: any) {
|
||||
onCheckedChange={() => toggleSelectAll()}
|
||||
/>
|
||||
</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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -411,9 +411,10 @@ export default function Show({ order }: any) {
|
||||
onCheckedChange={() => toggleSelect(key)}
|
||||
/>
|
||||
</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="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>
|
||||
</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="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="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>
|
||||
</div>
|
||||
</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">
|
||||
{item.max_quantity} {item.unit || item.unit_name}
|
||||
</TableCell>
|
||||
@@ -505,10 +515,10 @@ export default function Show({ order }: any) {
|
||||
<Input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
step="any"
|
||||
value={item.quantity}
|
||||
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>
|
||||
)}
|
||||
@@ -528,7 +538,7 @@ export default function Show({ order }: any) {
|
||||
</TableCell>
|
||||
{!isReadOnly && (
|
||||
<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" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
|
||||
@@ -40,6 +40,10 @@ export interface Product {
|
||||
location?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
cost_price?: number;
|
||||
price?: number;
|
||||
member_price?: number;
|
||||
wholesale_price?: number;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
|
||||
@@ -39,6 +39,8 @@ interface InventoryOption {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_code: string;
|
||||
warehouse_id: number;
|
||||
warehouse_name: string;
|
||||
batch_number: string;
|
||||
box_number: string | null;
|
||||
quantity: number;
|
||||
@@ -84,9 +86,9 @@ interface Props {
|
||||
|
||||
export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
|
||||
// 快取對照表:warehouse_id -> inventories
|
||||
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
||||
// 快取對照表:product_id -> inventories across warehouses
|
||||
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||
const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
|
||||
|
||||
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 }[],
|
||||
});
|
||||
|
||||
// 獲取倉庫資料的輔助函式
|
||||
const fetchWarehouseInventory = async (warehouseId: string) => {
|
||||
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
|
||||
// 獲取特定商品在各倉庫的庫存分佈
|
||||
const fetchProductInventories = async (productId: string) => {
|
||||
if (!productId) return;
|
||||
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
|
||||
if (loadingProducts[productId]) return;
|
||||
|
||||
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: true }));
|
||||
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
|
||||
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();
|
||||
setInventoryMap(prev => ({ ...prev, [warehouseId]: data }));
|
||||
setProductInventoryMap(prev => ({ ...prev, [productId]: data }));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} 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 item = { ...updated[index], [field]: value };
|
||||
|
||||
// 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. 當選擇商品變更時 -> 清空批號與相關資訊
|
||||
// 1. 當選擇商品變更時 -> 載入庫存分佈並重置後續欄位
|
||||
if (field === 'ui_product_id') {
|
||||
item.ui_warehouse_id = "";
|
||||
item.inventory_id = "";
|
||||
item.quantity_used = "";
|
||||
item.unit_id = "";
|
||||
@@ -193,24 +173,43 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
delete item.ui_large_unit_name;
|
||||
delete item.ui_base_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) {
|
||||
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
|
||||
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
||||
const inv = currentOptions.find(i => String(i.id) === value);
|
||||
if (inv) {
|
||||
item.ui_product_id = String(inv.product_id); // 確保商品也被選中 (雖通常是先選商品)
|
||||
item.ui_product_name = inv.product_name;
|
||||
item.ui_warehouse_id = String(inv.warehouse_id);
|
||||
item.ui_batch_number = inv.batch_number;
|
||||
item.ui_available_qty = inv.quantity;
|
||||
item.ui_expiry_date = inv.expiry_date || '';
|
||||
|
||||
// 單位與轉換率
|
||||
item.ui_base_unit_name = inv.base_unit_name || inv.unit_name || '';
|
||||
item.ui_large_unit_name = inv.large_unit_name || '';
|
||||
item.ui_base_unit_name = inv.unit_name || '';
|
||||
item.ui_base_unit_id = inv.base_unit_id;
|
||||
item.ui_large_unit_id = inv.large_unit_id;
|
||||
item.ui_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') {
|
||||
const inputQty = parseFloat(item.ui_input_quantity || '0');
|
||||
const rate = item.ui_conversion_rate || 1;
|
||||
|
||||
if (item.ui_selected_unit === 'large') {
|
||||
item.quantity_used = String(inputQty * rate);
|
||||
// 注意:後端需要的是 Base Unit ID? 這裡我們都送 Base Unit ID,因為 quantity_used 是 Base Unit
|
||||
// 但為了保留 User 的選擇,我們可能可以在 remark 註記? 目前先從簡
|
||||
item.unit_id = String(item.ui_base_unit_id || '');
|
||||
} else {
|
||||
item.quantity_used = String(inputQty);
|
||||
@@ -256,17 +252,21 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
|
||||
// 自動帶入配方標準產量
|
||||
setData('output_quantity', String(yieldQty));
|
||||
const ratio = 1;
|
||||
|
||||
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
|
||||
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 {
|
||||
inventory_id: "",
|
||||
quantity_used: String(calculatedQty),
|
||||
unit_id: String(item.unit_id),
|
||||
ui_warehouse_id: selectedWarehouse || "", // 自動帶入目前選擇的倉庫
|
||||
ui_warehouse_id: "",
|
||||
ui_product_id: String(item.product_id),
|
||||
ui_product_name: item.product_name,
|
||||
ui_batch_number: "",
|
||||
@@ -280,11 +280,6 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
});
|
||||
setBomItems(newBomItems);
|
||||
|
||||
// 若有選倉庫,預先載入庫存資料以供選擇
|
||||
if (selectedWarehouse) {
|
||||
fetchWarehouseInventory(selectedWarehouse);
|
||||
}
|
||||
|
||||
toast.success(`已自動載入配方: ${recipe.name}`, {
|
||||
description: `標準產量: ${yieldQty} 份`
|
||||
});
|
||||
@@ -503,7 +498,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
<Label className="text-xs font-medium text-grey-2">生產數量 *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
step="any"
|
||||
value={data.output_quantity}
|
||||
onChange={(e) => setData('output_quantity', e.target.value)}
|
||||
placeholder="例如: 50"
|
||||
@@ -607,8 +602,8 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<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-[25%]">批號 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[15%]">數量 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[15%]">單位</TableHead>
|
||||
@@ -617,61 +612,72 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItems.map((item, index) => {
|
||||
// 取得此列已載入的 Inventory Options
|
||||
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
|
||||
// 1. 商品選項
|
||||
const productOptions = products.map(p => ({
|
||||
label: `${p.name} (${p.code})`,
|
||||
value: String(p.id)
|
||||
}));
|
||||
|
||||
// 過濾商品
|
||||
const uniqueProductOptions = Array.from(new Map(
|
||||
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
|
||||
// 2. 來源倉庫選項 (根據商品库庫存過濾)
|
||||
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());
|
||||
|
||||
// 過濾批號
|
||||
const batchOptions = currentOptions
|
||||
.filter(inv => String(inv.product_id) === item.ui_product_id)
|
||||
.map(inv => ({
|
||||
// 如果篩選後沒有倉庫(即該商品無庫存),則顯示所有倉庫以供選取(或顯示無庫存提示)
|
||||
const uniqueWarehouseOptions = filteredWarehouseOptions.length > 0
|
||||
? filteredWarehouseOptions
|
||||
: (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}`,
|
||||
value: String(inv.id)
|
||||
}));
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<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. 選擇商品 */}
|
||||
<TableCell className="align-top">
|
||||
<SearchableSelect
|
||||
value={item.ui_product_id}
|
||||
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
|
||||
options={uniqueProductOptions}
|
||||
options={productOptions}
|
||||
placeholder="選擇商品"
|
||||
className="w-full"
|
||||
disabled={!item.ui_warehouse_id}
|
||||
/>
|
||||
</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">
|
||||
<SearchableSelect
|
||||
value={item.inventory_id}
|
||||
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
|
||||
options={batchOptions}
|
||||
placeholder={item.ui_product_id ? "選擇批號" : "請先選商品"}
|
||||
options={batchOptions as any}
|
||||
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
|
||||
className="w-full"
|
||||
disabled={!item.ui_product_id}
|
||||
disabled={!item.ui_warehouse_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 (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity}
|
||||
@@ -685,11 +691,11 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
step="any"
|
||||
value={item.ui_input_quantity}
|
||||
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
|
||||
placeholder="0"
|
||||
className="h-9"
|
||||
className="h-9 text-right"
|
||||
disabled={!item.inventory_id}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -38,6 +38,8 @@ interface InventoryOption {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_code: string;
|
||||
warehouse_id: number;
|
||||
warehouse_name: string;
|
||||
batch_number: string;
|
||||
box_number: string | null;
|
||||
quantity: number;
|
||||
@@ -73,6 +75,7 @@ interface BomItem {
|
||||
ui_large_unit_name?: string;
|
||||
ui_base_unit_id?: number;
|
||||
ui_large_unit_id?: number;
|
||||
ui_product_code?: string;
|
||||
}
|
||||
|
||||
interface ProductionOrderItem {
|
||||
@@ -136,23 +139,24 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
|
||||
); // 產出倉庫
|
||||
|
||||
// 快取對照表:warehouse_id -> inventories
|
||||
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
||||
// 快取對照表:product_id -> inventories
|
||||
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||
const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 獲取倉庫資料的輔助函式
|
||||
const fetchWarehouseInventory = async (warehouseId: string) => {
|
||||
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
|
||||
// 獲取商品所有倉庫庫存的分佈
|
||||
const fetchProductInventories = async (productId: string) => {
|
||||
if (!productId) return;
|
||||
if (loadingProducts[productId]) return;
|
||||
|
||||
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: true }));
|
||||
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
|
||||
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();
|
||||
setInventoryMap(prev => ({ ...prev, [warehouseId]: data }));
|
||||
setProductInventoryMap(prev => ({ ...prev, [productId]: data }));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} 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 }[],
|
||||
});
|
||||
|
||||
// 初始化載入既有 BOM 的來源倉庫資料
|
||||
// 初始化載入既有 BOM 的商品庫存資料
|
||||
useEffect(() => {
|
||||
initialBomItems.forEach(item => {
|
||||
if (item.ui_warehouse_id) {
|
||||
fetchWarehouseInventory(item.ui_warehouse_id);
|
||||
if (item.ui_product_id) {
|
||||
fetchProductInventories(item.ui_product_id);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 當 inventoryOptions (Map) 載入後,更新現有 BOM items 的詳細資訊 (如單位、轉換率)
|
||||
// 監聽 inventoryMap 變更
|
||||
// 當 inventoryOptions 載入後,更新現有 BOM items 的詳細資訊
|
||||
useEffect(() => {
|
||||
setBomItems(prevItems => prevItems.map(item => {
|
||||
if (item.ui_warehouse_id && inventoryMap[item.ui_warehouse_id] && item.inventory_id && !item.ui_conversion_rate) {
|
||||
const inv = inventoryMap[item.ui_warehouse_id].find(i => String(i.id) === item.inventory_id);
|
||||
const currentOptions = productInventoryMap[item.ui_product_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) {
|
||||
return {
|
||||
...item,
|
||||
ui_product_id: String(inv.product_id),
|
||||
ui_warehouse_id: String(inv.warehouse_id), // 重要:還原倉庫 ID
|
||||
ui_product_name: inv.product_name,
|
||||
ui_batch_number: inv.batch_number,
|
||||
ui_available_qty: inv.quantity,
|
||||
@@ -221,7 +225,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
}, [inventoryMap]);
|
||||
}, [productInventoryMap]);
|
||||
|
||||
// 同步 warehouse_id 到 form data
|
||||
useEffect(() => {
|
||||
@@ -251,53 +255,40 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
const updated = [...bomItems];
|
||||
const item = { ...updated[index], [field]: value };
|
||||
|
||||
// 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. 當選擇商品變更時 -> 清空批號與相關資訊
|
||||
// 0. 當選擇商品變更時 (第一層)
|
||||
if (field === 'ui_product_id') {
|
||||
item.ui_warehouse_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) {
|
||||
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. 當選擇批號變更時
|
||||
// 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) {
|
||||
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
|
||||
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
||||
const inv = currentOptions.find(i => String(i.id) === value);
|
||||
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_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>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
step="any"
|
||||
value={data.output_quantity}
|
||||
onChange={(e) => setData('output_quantity', e.target.value)}
|
||||
placeholder="例如: 50"
|
||||
@@ -583,8 +574,8 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<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-[25%]">批號 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[15%]">數量 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[15%]">單位</TableHead>
|
||||
@@ -593,19 +584,31 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItems.map((item, index) => {
|
||||
// 取得此列已載入的 Inventory Options
|
||||
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
|
||||
// 1. 商品選項
|
||||
const productOptions = products.map(p => ({
|
||||
label: `${p.name} (${p.code})`,
|
||||
value: String(p.id)
|
||||
}));
|
||||
|
||||
const uniqueProductOptions = Array.from(new Map(
|
||||
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
|
||||
// 2. 來源倉庫選項 (根據商品库庫存過濾)
|
||||
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());
|
||||
|
||||
// 在獲取前初始狀態的備案
|
||||
const displayProductOptions = uniqueProductOptions.length > 0 ? uniqueProductOptions : (item.ui_product_name ? [{ label: item.ui_product_name, value: item.ui_product_id }] : []);
|
||||
const uniqueWarehouseOptions = filteredWarehouseOptions.length > 0
|
||||
? filteredWarehouseOptions
|
||||
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
|
||||
|
||||
const batchOptions = currentOptions
|
||||
.filter(inv => String(inv.product_id) === item.ui_product_id)
|
||||
.map(inv => ({
|
||||
// 備案 (初始載入時)
|
||||
const displayWarehouseOptions = uniqueWarehouseOptions.length > 0
|
||||
? 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}`,
|
||||
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 }] : []);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<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. 選擇商品 */}
|
||||
<TableCell className="align-top">
|
||||
<SearchableSelect
|
||||
value={item.ui_product_id}
|
||||
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
|
||||
options={displayProductOptions}
|
||||
options={productOptions}
|
||||
placeholder="選擇商品"
|
||||
className="w-full"
|
||||
disabled={!item.ui_warehouse_id}
|
||||
/>
|
||||
</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">
|
||||
<SearchableSelect
|
||||
value={item.inventory_id}
|
||||
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
|
||||
options={displayBatchOptions}
|
||||
placeholder={item.ui_product_id ? "選擇批號" : "請先選商品"}
|
||||
options={displayBatchOptions as any}
|
||||
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
|
||||
className="w-full"
|
||||
disabled={!item.ui_product_id}
|
||||
disabled={!item.ui_warehouse_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 (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity}
|
||||
@@ -665,11 +671,11 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
step="any"
|
||||
value={item.ui_input_quantity}
|
||||
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
|
||||
placeholder="0"
|
||||
className="h-9"
|
||||
className="h-9 text-right"
|
||||
disabled={!item.inventory_id}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
* 新增配方頁面
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, useForm, Link } from "@inertiajs/react";
|
||||
import { toast } from "sonner";
|
||||
import { Head, useForm, Link } from "@inertiajs/react";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
@@ -36,6 +35,7 @@ interface RecipeItem {
|
||||
// UI Helpers
|
||||
ui_product_name?: string;
|
||||
ui_product_code?: string;
|
||||
ui_unit_name?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -91,9 +91,11 @@ export default function RecipeCreate({ products, units }: Props) {
|
||||
if (product) {
|
||||
newItems[index].ui_product_name = product.name;
|
||||
newItems[index].ui_product_code = product.code;
|
||||
// Default to base unit
|
||||
// Default to base unit and fix it
|
||||
if (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) => {
|
||||
e.preventDefault();
|
||||
post(route('recipes.store'), {
|
||||
onSuccess: () => {
|
||||
toast.success("配方已建立");
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error("儲存失敗,請檢查欄位");
|
||||
}
|
||||
});
|
||||
post(route('recipes.store'));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -195,6 +190,7 @@ export default function RecipeCreate({ products, units }: Props) {
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={data.yield_quantity}
|
||||
onChange={(e) => setData('yield_quantity', e.target.value)}
|
||||
placeholder="1"
|
||||
@@ -269,23 +265,17 @@ export default function RecipeCreate({ products, units }: Props) {
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
step="any"
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
||||
placeholder="數量"
|
||||
className="text-right"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<SearchableSelect
|
||||
value={item.unit_id}
|
||||
onValueChange={(v) => updateItem(index, 'unit_id', v)}
|
||||
options={units.map(u => ({
|
||||
label: u.name,
|
||||
value: String(u.id)
|
||||
}))}
|
||||
placeholder="單位"
|
||||
className="w-full"
|
||||
/>
|
||||
<TableCell className="align-middle">
|
||||
<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">
|
||||
{item.ui_unit_name || (units.find(u => String(u.id) === item.unit_id)?.name) || '-'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
* 編輯配方頁面
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, useForm, Link } from "@inertiajs/react";
|
||||
import { toast } from "sonner";
|
||||
import { Head, useForm, Link } from "@inertiajs/react";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
@@ -59,6 +58,7 @@ interface RecipeItemForm {
|
||||
// UI Helpers
|
||||
ui_product_name?: string;
|
||||
ui_product_code?: string;
|
||||
ui_unit_name?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -80,7 +80,8 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
||||
unit_id: String(item.unit_id),
|
||||
remark: item.remark || "",
|
||||
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[],
|
||||
});
|
||||
|
||||
@@ -118,6 +119,8 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
||||
// Default to base unit if not set
|
||||
if (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) => {
|
||||
e.preventDefault();
|
||||
put(route('recipes.update', recipe.id), {
|
||||
onSuccess: () => {
|
||||
toast.success("配方已更新");
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error("儲存失敗,請檢查欄位");
|
||||
}
|
||||
});
|
||||
put(route('recipes.update', recipe.id));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -219,6 +215,7 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={data.yield_quantity}
|
||||
onChange={(e) => setData('yield_quantity', e.target.value)}
|
||||
placeholder="1"
|
||||
@@ -293,23 +290,17 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
step="any"
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
||||
placeholder="數量"
|
||||
className="text-right"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<SearchableSelect
|
||||
value={item.unit_id}
|
||||
onValueChange={(v) => updateItem(index, 'unit_id', v)}
|
||||
options={units.map(u => ({
|
||||
label: u.name,
|
||||
value: String(u.id)
|
||||
}))}
|
||||
placeholder="單位"
|
||||
className="w-full"
|
||||
/>
|
||||
<TableCell className="align-middle">
|
||||
<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">
|
||||
{item.ui_unit_name || (units.find(u => String(u.id) === item.unit_id)?.name) || '-'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function CreatePurchaseOrder({
|
||||
}
|
||||
|
||||
if (!orderDate) {
|
||||
toast.error("請選擇採購日期");
|
||||
toast.error("請選擇下單日期");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ export default function CreatePurchaseOrder({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold text-gray-700">
|
||||
採購日期 <span className="text-red-500">*</span>
|
||||
下單日期 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
@@ -342,7 +342,7 @@ export default function CreatePurchaseOrder({
|
||||
onChange={(e) => setInvoiceAmount(e.target.value)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
step="0.01"
|
||||
step="any"
|
||||
className="block w-full"
|
||||
/>
|
||||
{invoiceAmount && totalAmount > 0 && parseFloat(invoiceAmount) !== totalAmount && (
|
||||
@@ -419,6 +419,7 @@ export default function CreatePurchaseOrder({
|
||||
<div className="relative w-32">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={taxAmount}
|
||||
onChange={(e) => {
|
||||
setTaxAmount(e.target.value);
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
|
||||
<span className="font-medium text-gray-900">{formatDateTime(order.createdAt)}</span>
|
||||
</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>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
334
resources/js/Pages/ShippingOrder/Create.tsx
Normal file
334
resources/js/Pages/ShippingOrder/Create.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
resources/js/Pages/ShippingOrder/Index.tsx
Normal file
207
resources/js/Pages/ShippingOrder/Index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
resources/js/Pages/ShippingOrder/Show.tsx
Normal file
236
resources/js/Pages/ShippingOrder/Show.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -23,14 +23,17 @@ import { Warehouse, InboundItem, InboundReason } from "@/types/warehouse";
|
||||
import { getCurrentDateTime } from "@/utils/format";
|
||||
import { toast } from "sonner";
|
||||
import { getInventoryBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import ScannerInput from "@/Components/Inventory/ScannerInput";
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
barcode?: string;
|
||||
baseUnit: string;
|
||||
largeUnit?: string;
|
||||
conversionRate?: number;
|
||||
costPrice?: number;
|
||||
}
|
||||
|
||||
interface Batch {
|
||||
@@ -113,9 +116,101 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
});
|
||||
}, [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 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 = {
|
||||
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
productId: defaultProduct.id,
|
||||
@@ -128,6 +223,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
selectedUnit: 'base',
|
||||
batchMode: 'existing', // 預設選擇現有批號
|
||||
originCountry: 'TW',
|
||||
unit_cost: defaultProduct.costPrice || 0,
|
||||
};
|
||||
setItems([...items, newItem]);
|
||||
};
|
||||
@@ -162,6 +258,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
batchMode: 'existing',
|
||||
inventoryId: undefined, // 清除已選擇的批號
|
||||
expiryDate: undefined,
|
||||
unit_cost: product.costPrice || 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -224,7 +321,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
batchMode: item.batchMode,
|
||||
inventoryId: item.inventoryId,
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* 掃碼輸入區 */}
|
||||
<ScannerInput
|
||||
onScan={handleScan}
|
||||
className="bg-gray-50/50"
|
||||
/>
|
||||
|
||||
{errors.items && (
|
||||
<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]">
|
||||
批號 <span className="text-red-500">*</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
單價
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
數量 <span className="text-red-500">*</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[90px]">單位</TableHead>
|
||||
<TableHead className="w-[100px]">轉換數量</TableHead>
|
||||
<TableHead className="w-[140px]">效期</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -479,6 +584,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
)}
|
||||
|
||||
{item.batchMode === 'new' && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
@@ -496,29 +602,72 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
{getBatchPreview(item.productId, product?.code, item.originCountry || 'TW', inboundDate)}
|
||||
</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 && (
|
||||
<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 || '未設定'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
step="any"
|
||||
value={item.quantity || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateItem(item.tempId, {
|
||||
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`] && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors[`item-${index}-quantity`]}
|
||||
@@ -544,48 +693,20 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
className="border-gray-300"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={item.baseUnit || "個"}
|
||||
disabled
|
||||
className="bg-gray-50 border-gray-200"
|
||||
/>
|
||||
<div className="text-sm text-gray-700 font-medium px-3 py-2 bg-gray-50 border border-gray-200 rounded-md">
|
||||
{item.baseUnit || "個"}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function EditInventory({ warehouse, inventory, transactions = []
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
step="any"
|
||||
value={data.quantity}
|
||||
onChange={(e) =>
|
||||
setData("quantity", parseFloat(e.target.value) || 0)
|
||||
|
||||
@@ -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 (
|
||||
<AuthenticatedLayout breadcrumbs={getInventoryBreadcrumbs(warehouse.id, warehouse.name)}>
|
||||
@@ -195,7 +186,6 @@ export default function WarehouseInventoryPage({
|
||||
inventories={filteredInventories}
|
||||
onView={handleView}
|
||||
onDelete={confirmDelete}
|
||||
onAdjust={handleAdjust}
|
||||
onViewProduct={handleViewProduct}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user