Compare commits

33 Commits

Author SHA1 Message Date
746eeb6f01 更新:優化配方詳情彈窗 UI 與一般修正
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
2026-01-29 16:13:56 +08:00
7619dc24f7 feat(inventory): 統一庫存調整與調撥模組 UI,實作多選、搜尋與明細欄位重構
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m4s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-29 14:37:21 +08:00
2efaded77b 統一庫存盤點與盤調 UI 及邏輯:修正狀態顯示、操作權限與列表樣式
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-29 13:41:31 +08:00
a31c8d6052 feat: add void action to inventory count index
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m9s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-29 13:12:02 +08:00
56e30a85bb refactor: changes to inventory status (approved/unapprove)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m6s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-29 13:04:54 +08:00
46753cc3bc fix(auth): 使用 Inertia::location 修復登入後重定向失敗問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
2026-01-29 10:25:37 +08:00
7f726e80bd fix(config): 更新 Session Cookie 名稱以強制解決瀏覽器舊 Cookie 衝突
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 44s
2026-01-29 10:18:00 +08:00
8bc95db43d fix(auth): 登出時強制清除 Session Cookie 以解決二次登入問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 47s
2026-01-29 10:08:57 +08:00
95a1763d04 fix(framework): 修正 TrustProxies 配置以解決 HTTPS 識別問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 42s
2026-01-29 10:04:32 +08:00
90cb7a82de fix(deploy): 恢復 node_modules 排除清單
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 47s
2026-01-29 10:00:04 +08:00
bbb2c4c4a3 style(deploy): 移除多餘空行
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
2026-01-29 09:56:13 +08:00
8cb95e1a56 fix(deploy): 修正正式環境部署漏掉 storage 排除清單導致檔案遺失的問題 2026-01-29 09:51:36 +08:00
fc59c86305 fix(deploy): 確保每次部署後重建 storage 軟連結
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 44s
2026-01-29 09:48:59 +08:00
b613cdb796 chore(docker): 啟動時自動檢查並建立 storage 軟連結
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
2026-01-29 09:45:12 +08:00
b1745555cc feat(tenancy): 租戶初始化流程新增自動補全基本單位資料 2026-01-29 09:38:23 +08:00
1833ca192d feat(inventory): 優化盤點顯示與權限設定 2026-01-29 09:36:07 +08:00
e5edad4fd0 style: 修正盤點與盤調畫面 Table Padding 並統一 UI 規範
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m4s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-28 18:04:45 +08:00
852370cfe0 fix(db): add activity_log migrations to central database
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 49s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-28 14:10:53 +08:00
965418077b fix(ui): provide default branding for central admin
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 40s
2026-01-28 14:01:08 +08:00
c3af92c85c feat(ui): dynamic page title based on tenant context
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
2026-01-28 13:58:54 +08:00
cca49b5fe8 feat(assets): add default tenant logo and login background
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 44s
2026-01-28 13:49:24 +08:00
d4cef2cd84 fix(tenancy): force seeders in production and set default branding
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 51s
2026-01-28 13:45:28 +08:00
4c959efc8b feat: 補齊生產管理與進貨單權限、功能實作及 UI 優化
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 17:40:56 +08:00
95d8dc2e84 feat: 統一進貨單 UI、修復庫存異動紀錄與廠商詳情顯示報錯
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 51s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 17:23:31 +08:00
a7c445bd3f fix: 修正部分進貨採購單更新失敗與狀態顯示問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 13:27:28 +08:00
293358df62 refactor(inventory): 重構倉庫管理邏輯,移除 is_sellable 欄位並改由類型判定可用庫存 2026-01-27 10:23:49 +08:00
1ed3d6a29d docs(ui-consistency): 優化規範文件,明確標準操作優先使用主題色 2026-01-27 10:15:50 +08:00
646435f87a style(production): 修正檢視按鈕樣式為主題色並保留權限控制 2026-01-27 10:11:16 +08:00
f10c31abd0 style(production): 補齊生產工單列表檢視按鈕 UI 與權限控制 2026-01-27 10:10:10 +08:00
046e0a028b style(production): 統一生產模組操作圖示 UI、權限控制與 AlertDialog 2026-01-27 10:09:43 +08:00
ce0a7b3409 feat(procurement): 採購單號格式增加 PO 前綴 2026-01-27 10:05:46 +08:00
084bbc9f53 docs: 再次確認 framework.md 更新內容並同步
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 09:18:07 +08:00
3af4a1e298 docs: 更新開發框架規範,加入嚴格模組化通訊規範
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 09:15:05 +08:00
110 changed files with 9412 additions and 384 deletions

View File

@@ -50,7 +50,16 @@ trigger: always_on
* Routes: `kebab-case` (小寫橫線分隔)
* **回傳格式** 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
## 6. AI 協作規則 (給 Antigravity AI)
## 6. 嚴格模組化通訊規範 (Strict Modular Communication)
為了確保系統的可維護性與獨立性,所有模組必須遵守以下「實體解耦」規範:
* **禁止跨模組 Eloquent 關聯**:禁止在 Model 中定義指向其他模組的 `belongsTo`, `hasMany` 等關聯。
* **介面化通訊 (Contracts)**:模組間的資料交換與功能調用必須透過 `app/Modules/{ModuleName}/Contracts/` 下定義的介面進行。
* **禁止跨模組 Model 引用**Controller 與 Service 禁止 `use` 其他模組的 Model (除非是該模組自身的 Contracts)。
* **手動資料水和 (Manual Hydration)**若頁面需要顯示跨模組資料訂單顯示使用者名稱Controller 應透過 Service 獲取基本資料,再手動組合成前端所需的 JSON/Props 結構。
* **資料一致性**:跨模組的資料操作應由各模組的 Service 處理其內部的 transaction 完整性。
## 7. AI 協作規則 (給 Antigravity AI)
* **角色設定** 你是一位專業的全端開發工程師助手。
* **代碼生成指令**
* 所有的解釋說明請使用 **繁體中文**。
@@ -58,7 +67,7 @@ trigger: always_on
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
* 新增功能時,請先判斷應歸屬於哪個 Module並建立在 `app/Modules/` 對應目錄下。
## 7. 運行機制 (Docker / Sail)
## 8. 運行機制 (Docker / Sail)
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
* **啟動環境** `./vendor/bin/sail up -d`

View File

@@ -123,8 +123,8 @@ tooltip
// ✅ 成功操作
<Button className="button-filled-success">確認</Button>
// ✅ 資訊操作
<Button className="button-filled-info">查看詳情</Button>
// ✅ 資訊操作(用於系統提示、說明等非業務主流程)
<Button className="button-filled-info">系統資訊</Button>
// ✅ 警告操作
<Button className="button-filled-warning">警告</Button>
@@ -177,6 +177,23 @@ tooltip
</Can>
```
#### 表格操作列檢視按鈕
```tsx
<Can permission="resource.view">
<Link href={route('resource.show', item.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="檢視"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
</Can>
```
#### 表格操作列編輯按鈕
```tsx
@@ -230,6 +247,30 @@ tooltip
</Can>
```
### 3.4 返回按鈕規範
詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。
**樣式規格**
- **位置**:標題區域上方 (`mb-6`),獨立於標題列
- **樣式**`variant="outline"` + `className="gap-2 button-outlined-primary"`
- **圖標**`<ArrowLeft className="h-4 w-4" />`
- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」
```tsx
<div className="mb-6">
<Link href={route('resource.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary"
>
<ArrowLeft className="h-4 w-4" />
返回列表
</Button>
</Link>
</div>
```
---
## 4. 圖標規範

View File

@@ -0,0 +1,158 @@
---
name: 操作紀錄實作規範
description: 規範系統內 Activity Log 的實作標準,包含後端資料過濾、快照策略、與前端顯示邏輯。
---
# 操作紀錄實作規範
本文件說明如何在開發新功能時,依據系統規範實作 `spatie/laravel-activitylog` 操作紀錄,確保資料儲存效率與前端顯示一致性。
## 1. 後端實作標準 (Backend)
所有 Model 之操作紀錄應遵循「僅儲存變動資料」與「保留關鍵快照」兩大原則。
### 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` (推薦)**
```php
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
// 保存關鍵關聯名稱 (避免關聯資料刪除後 ID 失效)
$snapshot['category_name'] = $this->category ? $this->category->name : null;
$snapshot['po_number'] = $this->code; // 儲存單號
// 保存自身名稱 (Context)
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
```
## 2. 顯示名稱映射 (UI Mapping)
### 2.1 對象名稱映射 (Mapping)
需在 `ActivityLogController.php` 中設定 Model 與中文名稱的對應,讓前端列表能顯示中文對象(如「公共事業費」而非 `UtilityFee`)。
**位置**: `app/Http/Controllers/Admin/ActivityLogController.php`
```php
protected function getSubjectMap()
{
return [
'App\Modules\Inventory\Models\Product' => '商品',
'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
];
}
```
### 2.2 欄位名稱中文化 (Field Translation)
需在前端 `ActivityDetailDialog` 中設定欄位名稱的中文翻譯。
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
```typescript
const fieldLabels: Record<string, string> = {
// ... 既有欄位
'transaction_date': '費用日期',
'category': '費用類別',
'amount': '金額',
};
```
## 3. 前端顯示邏輯 (Frontend)
### 3.1 列表描述生成 (Description Generation)
前端 `LogTable.tsx` 會依據 `properties.snapshot` 中的欄位自動組建描述例如「Admin 新增 電話費 公共事業費」)。
若您的 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`

View File

@@ -0,0 +1,140 @@
---
name: 權限管理與實作規範
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
---
# 權限管理與實作規範
本文件說明如何在新增功能時,一併實作完整的權限控制機制。專案採用 `spatie/laravel-permission` 套件進行權限管理。
## 1. 定義權限 (Backend)
所有權限皆定義於 `database/seeders/PermissionSeeder.php`
### 步驟:
1. 開啟 `database/seeders/PermissionSeeder.php`
2. 在 `$permissions` 陣列中新增功能對應的權限字串。
* **命名慣例**`{resource}.{action}` (例如:`system.view_logs`, `products.create`)
* 常用動作:`view`, `create`, `edit`, `delete`, `publish`, `export`
3. 在下方「角色分配」區段,將新權限分配給適合的角色。
* `super-admin`:通常擁有所有權限(程式碼中 `Permission::all()` 自動涵蓋,無需手動新增)。
* `admin`:通常擁有大部分權限。
* 其他角色 (`warehouse-manager`, `purchaser`, `viewer`):依業務邏輯分配。
### 範例:
```php
// 1. 新增權限字串
$permissions = [
// ... 現有權限
'system.view_logs', // 新增:檢視系統日誌
];
// ...
// 2. 分配給角色
$admin->givePermissionTo([
// ... 現有權限
'system.view_logs',
]);
```
## 2. 套用資料庫變更
修改 Seeder 後,必須重新執行 Seeder 以將權限寫入資料庫。
```bash
# 對於所有租戶執行 Seeder (開發環境)
php artisan tenants:seed --class=PermissionSeeder
```
## 3. 路由保護 (Backend Middleware)
`routes/web.php` 中,使用 `permission:{name}` middleware 保護路由。
### 範例:
```php
// 單一權限保護
Route::get('/logs', [LogController::class, 'index'])
->middleware('permission:system.view_logs')
->name('logs.index');
// 路由群組保護
Route::middleware('permission:products.view')->group(function () {
// ...
});
// 多重權限 (OR 邏輯:有其一即可)
Route::middleware('permission:products.create|products.edit')->group(function () {
// ...
});
```
## 4. 前端權限判斷 (React Component)
使用自訂 Hook `usePermission` 來控制 UI 元素的顯示(例如:隱藏沒有權限的按鈕)。
### 引入 Hook
```tsx
import { usePermission } from "@/hooks/usePermission";
```
### 使用方式:
```tsx
export default function ProductIndex() {
const { can } = usePermission();
return (
<div>
<h1>商品列表</h1>
{/* 只有擁有 create 權限才顯示按鈕 */}
{can('products.create') && (
<Button>新增商品</Button>
)}
{/* 組合判斷 */}
{can('products.edit') && <EditButton />}
</div>
);
}
```
### 權限 Hook 介面說明:
- `can(permission: string)`: 檢查當前使用者是否擁有指定權限。
- `canAny(permissions: string[])`: 檢查當前使用者是否擁有陣列中**任一**權限。
- `hasRole(role: string)`: 檢查當前使用者是否擁有指定角色。
## 5. 配置權限群組名稱 (Backend UI Config)
為了讓新權限在「角色與權限」管理介面中顯示正確的中文分組標題,需修改 Controller 設定。
### 步驟:
1. 開啟 `app/Http/Controllers/Admin/RoleController.php`
2. 找到 `getGroupedPermissions` 方法。
3. 在 `$groupDefinitions` 陣列中,新增 `{resource}` 對應的中文名稱。
### 範例:
```php
$groupDefinitions = [
'products' => '商品資料管理',
// ...
'utility_fees' => '公共事業費管理', // 新增此行
];
```
## 檢核清單
- [ ] `PermissionSeeder.php` 已新增權限字串。
- [ ] `PermissionSeeder.php` 已將新權限分配給對應角色。
- [ ] 已執行 `php artisan tenants:seed --class=PermissionSeeder` 更新資料庫。
- [ ] `RoleController.php` 已新增權限群組的中文名稱映射。
- [ ] 後端路由 (`routes/web.php`) 已加上 middleware 保護。
- [ ] 前端頁面/按鈕已使用 `usePermission` 進行顯示控制。

View File

@@ -0,0 +1,990 @@
---
name: 客戶端後台 UI 統一規範
description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為
---
# 客戶端後台 UI 統一規範
## 概述
本技能提供 Star ERP 系統**客戶端(租戶端)後台**的 UI 統一性規範,確保所有頁面使用一致的元件、樣式類別、圖標和佈局模式。
> **適用範圍**:本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。
## 核心原則
1. **使用統一的 UI 組件庫**:優先使用 `@/Components/ui/` 中的 47 個元件
2. **遵循既定的樣式類別**:使用 `app.css` 中定義的自定義按鈕類別
3. **統一的圖標系統**:全面使用 `lucide-react` 圖標
4. **一致的佈局模式**:表格、分頁、操作按鈕等保持相同結構
5. **權限控制**:所有操作按鈕必須使用 `<Can>` 元件包裹
---
## 1. 專案結構
### 1.1 關鍵目錄
```
resources/
├── css/
│ └── app.css # 全域樣式與設計 Token
├── js/
│ ├── Components/
│ │ ├── ui/ # 47 個基礎 UI 元件 (shadcn/ui)
│ │ ├── shared/ # 共用業務元件 (Pagination, BreadcrumbNav 等)
│ │ └── Permission/ # 權限控制元件 (Can, HasRole, CanAll)
│ ├── Layouts/
│ │ ├── AuthenticatedLayout.tsx # 客戶端後台佈局 ⬅️ 本規範適用
│ │ └── LandlordLayout.tsx # 中央管理後台佈局
│ └── Pages/ # 頁面元件
```
### 1.2 可用 UI 元件清單
```
accordion, alert, alert-dialog, avatar, badge, breadcrumb, button,
calendar, card, carousel, chart, checkbox, collapsible, command,
context-menu, dialog, drawer, dropdown-menu, form, hover-card,
input, input-otp, label, menubar, navigation-menu, pagination,
popover, progress, radio-group, resizable, scroll-area,
searchable-select, select, separator, sheet, sidebar, skeleton,
slider, sonner, switch, table, tabs, textarea, toggle, toggle-group,
tooltip
```
---
## 2. 色彩系統
### 2.1 主題色 (Primary) - **動態租戶品牌色**
> **注意**主題色會根據租戶設定Branding動態改變**嚴禁**在程式碼中 Hardcode 色碼(如 `#01ab83`)。
> 請務必使用 Tailwind Utility Class 或 CSS 變數。
| Tailwind Class | CSS Variable | 說明 |
|----------------|--------------|------|
| `*-primary-main` | `--primary-main` | **主色**:與租戶設定一致(預設綠色),用於主要按鈕、連結、強調文字 |
| `*-primary-dark` | `--primary-dark` | **深色**:系統自動計算,用於 Hover 狀態 |
| `*-primary-light` | `--primary-light` | **淺色**:系統自動計算,用於次要強調 |
| `*-primary-lightest` | `--primary-lightest` | **最淺色**系統自動計算用於背景底色、Active 狀態 |
**運作機制**
`AuthenticatedLayout` 會根據後端回傳的 `branding` 資料,自動注入 CSS 變數覆寫預設值。
```tsx
// ✅ 正確:使用 Tailwind Class
<div className="text-primary-main">...</div>
// ✅ 正確:使用 CSS 變數 (自定義樣式時)
<div style={{ borderColor: 'var(--primary-main)' }}>...</div>
// ❌ 錯誤:寫死色碼 (會導致租戶無法換色)
<div className="text-[#01ab83]">...</div>
```
### 2.2 灰階 (Grey Scale)
```css
--grey-0: #1a1a1a; /* 深黑 - 標題文字 */
--grey-1: #4a4a4a; /* 深灰 - 主要內文 */
--grey-2: #6b6b6b; /* 中灰 - 次要內文、Placeholder */
--grey-3: #9e9e9e; /* 淺灰 - 禁用文字、輔助說明 */
--grey-4: #e0e0e0; /* 極淺灰 - 邊框、分隔線 */
--grey-5: #fff; /* 白色 - 背景、按鈕文字 */
```
### 2.3 狀態色 (State Colors)
```css
--other-success: #01ab83; /* 成功 - 同主題色 */
--other-error: #dc2626; /* 錯誤 - 刪除、警示 */
--other-warning: #f59e0b; /* 警告 - 提醒、注意 */
--other-info: #3b82f6; /* 資訊 - 說明、提示 */
```
---
## 3. 按鈕規範
### 3.1 按鈕樣式類別
專案在 `resources/css/app.css` 中定義了統一的按鈕樣式,**必須**使用這些類別:
#### Filled 按鈕(實心按鈕)— 用於主要操作
```tsx
// ✅ 主要操作按鈕(綠色主題色)- 新增、儲存、確認
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增項目
</Button>
// ✅ 成功操作
<Button className="button-filled-success">確認</Button>
// ✅ 資訊操作(用於系統提示、說明等非業務主流程)
<Button className="button-filled-info">系統資訊</Button>
// ✅ 警告操作
<Button className="button-filled-warning">警告</Button>
// ✅ 錯誤/刪除操作AlertDialog 內確認按鈕)
<Button className="button-filled-error">刪除</Button>
```
#### Outlined 按鈕(邊框按鈕)— 用於次要操作
```tsx
// ✅ 編輯按鈕(表格操作列)
<Button variant="outline" size="sm" className="button-outlined-primary">
<Pencil className="h-4 w-4" />
</Button>
// ✅ 刪除按鈕(表格操作列)
<Button variant="outline" size="sm" className="button-outlined-error">
<Trash2 className="h-4 w-4" />
</Button>
```
#### Text 按鈕(文字按鈕)
```tsx
<Button className="button-text-primary">查看更多</Button>
```
### 3.2 按鈕大小
| Size | 高度 | 使用情境 |
|------|------|----------|
| `size="sm"` | h-8 | 表格操作列、緊湊佈局 |
| `size="default"` | h-9 | 一般操作、表單提交 |
| `size="lg"` | h-10 | 主要 CTA、頁面主操作 |
| `size="icon"` | 9×9 | 純圖標按鈕 |
### 3.3 常見操作按鈕模式
#### 頁面頂部新增按鈕
```tsx
<Can permission="resource.create">
<Link href={route('resource.create')}>
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增XXX
</Button>
</Link>
</Can>
```
#### 表格操作列檢視按鈕
```tsx
<Can permission="resource.view">
<Link href={route('resource.show', item.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="檢視"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
</Can>
```
#### 表格操作列編輯按鈕
```tsx
<Can permission="resource.edit">
<Link href={route('resource.edit', item.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
</Can>
```
#### 表格操作列刪除按鈕(帶確認對話框)
```tsx
<Can permission="resource.delete">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>確認刪除</AlertDialogTitle>
<AlertDialogDescription>
確定要刪除「{item.name}」嗎?此操作無法復原。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(item.id)}
className="bg-red-600 hover:bg-red-700"
>
刪除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
```
### 3.4 返回按鈕規範
詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。
**樣式規格**
- **位置**:標題區域上方 (`mb-6`),獨立於標題列
- **樣式**`variant="outline"` + `className="gap-2 button-outlined-primary"`
- **圖標**`<ArrowLeft className="h-4 w-4" />`
- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」
```tsx
<div className="mb-6">
<Link href={route('resource.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary"
>
<ArrowLeft className="h-4 w-4" />
返回列表
</Button>
</Link>
</div>
```
---
## 4. 圖標規範
### 4.1 統一使用 lucide-react
**統一使用 `lucide-react`**,禁止使用其他圖標庫(如 FontAwesome、Material Icons、react-icons 等)。
### 4.2 圖標尺寸標準
| 尺寸 | 類別 | 使用情境 |
|------|------|----------|
| 小型 | `h-3 w-3` | Badge 內、小文字旁 |
| 標準 | `h-4 w-4` | 按鈕內、表格操作 |
| 標題 | `h-5 w-5` | 側邊欄選單 |
| 大型 | `h-6 w-6` | 頁面標題 |
### 4.3 常用操作圖標映射
| 操作 | 圖標組件 | 使用情境 |
|------|----------|----------|
| 新增 | `<Plus />` | 新增按鈕 |
| 編輯 | `<Pencil />` | 編輯按鈕 |
| 刪除 | `<Trash2 />` | 刪除按鈕 |
| 查看 | `<Eye />` | 查看詳情 |
| 搜尋 | `<Search />` | 搜尋欄位 |
| 篩選 | `<Filter />` | 篩選功能 |
| 下載 | `<Download />` | 下載/匯出 |
| 上傳 | `<Upload />` | 上傳/匯入 |
| 設定 | `<Settings />` | 設定功能 |
| 複製 | `<Copy />` | 複製內容 |
| 郵件 | `<Mail />` | Email 顯示 |
| 使用者 | `<Users />`, `<User />` | 使用者管理 |
| 權限 | `<Shield />` | 角色/權限 |
| 排序 | `<ArrowUpDown />`, `<ArrowUp />`, `<ArrowDown />` | 表格排序 |
| 儀表板 | `<LayoutDashboard />` | 首頁/總覽 |
| 商品 | `<Package />` | 商品管理 |
| 倉庫 | `<Warehouse />` | 倉庫管理 |
| 廠商 | `<Truck />`, `<Contact2 />` | 廠商管理 |
| 採購 | `<ShoppingCart />` | 採購管理 |
### 4.4 圖標使用範例
```tsx
import { Plus, Pencil, Trash2, Users } from 'lucide-react';
// 頁面標題
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Users className="h-6 w-6 text-[#01ab83]" />
使用者管理
</h1>
// 按鈕內圖標(圖標在左,帶文字)
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增使用者
</Button>
// 純圖標按鈕(表格操作列)
<Button variant="outline" size="sm" className="button-outlined-primary">
<Pencil className="h-4 w-4" />
</Button>
```
---
## 5. 表格規範
### 5.1 表格容器
```tsx
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
{/* 表格內容 */}
</Table>
</div>
```
### 5.2 表格標題列
```tsx
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead>名稱</TableHead>
<TableHead className="text-center">操作</TableHead>
</TableRow>
</TableHeader>
```
**關鍵要點**
- 使用 `bg-gray-50` 背景色
- 序號欄位固定寬度 `w-[50px]` 並置中
- 操作欄位置中顯示
### 5.3 表格主體
```tsx
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-gray-500">
無符合條件的資料
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-gray-500 font-medium text-center">
{startIndex + index}
</TableCell>
{/* 其他欄位 */}
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
{/* 操作按鈕 */}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
```
**關鍵要點**
- 空狀態訊息使用置中、灰色文字
- 序號欄使用 `text-gray-500 font-medium text-center`
- 操作欄使用 `flex items-center justify-center gap-2` 排列按鈕
### 5.4 欄位排序規範
當表格需要支援排序時,請遵循以下模式:
1. **圖標邏輯**
* 未排序:`ArrowUpDown` (class: `text-muted-foreground`)
* 升冪 (asc)`ArrowUp` (class: `text-primary`)
* 降冪 (desc)`ArrowDown` (class: `text-primary`)
2. **結構**:在 `TableHead` 內使用 `button` 元素。
3. **後端配合**:後端 Controller **必須** 處理 `sort_by``sort_order` 參數。
```tsx
// 1. 定義 Helper Component (在元件內部)
const SortIcon = ({ field }: { field: string }) => {
if (filters.sort_by !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
}
if (filters.sort_order === "asc") {
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
}
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
};
// 2. 表格標題應用
<TableHead>
<button
onClick={() => handleSort('created_at')}
className="flex items-center hover:text-gray-900"
>
建立時間 <SortIcon field="created_at" />
</button>
</TableHead>
// 3. 排序處理函式 (三態切換:未排序 -> 升冪 -> 降冪 -> 未排序)
const handleSort = (field: string) => {
let newSortBy: string | undefined = field;
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
if (filters.sort_by === field) {
if (filters.sort_order === 'asc') {
newSortOrder = 'desc';
} else {
// desc -> reset (回到預設排序)
newSortBy = undefined;
newSortOrder = undefined;
}
}
router.get(
route(route().current()!),
{ ...filters, sort_by: newSortBy, sort_order: newSortOrder },
{ preserveState: true, replace: true }
);
};
```
---
## 6. 分頁規範
### 6.1 統一分頁元件
使用 `@/Components/shared/Pagination` 元件:
```tsx
import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select";
// 在表格下方
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>每頁顯示</span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[80px] h-8"
showSearch={false}
/>
<span>筆</span>
</div>
<Pagination links={data.links} />
</div>
```
### 6.2 每頁筆數狀態管理
```tsx
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route('resource.index'),
{ per_page: value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
```
---
## 7. Badge 與狀態顯示
### 7.1 基本 Badge
```tsx
import { Badge } from "@/Components/ui/badge";
// Outline 樣式(最常用)
<Badge variant="outline">{item.category?.name || '-'}</Badge>
// 預設樣式(主題色背景)
<Badge variant="default">啟用中</Badge>
// 錯誤樣式
<Badge variant="destructive">停用</Badge>
```
### 7.2 角色顯示(特殊樣式)
```tsx
<div className="flex flex-wrap gap-2">
{user.roles.map(role => (
<div
key={role.id}
className={cn(
"inline-flex items-center px-2.5 py-1 rounded-md border",
role.name === 'super-admin'
? "bg-purple-50 border-purple-200"
: "bg-gray-50 border-gray-200"
)}
>
<div className="flex items-center gap-1.5">
{role.name === 'super-admin' && <Shield className="h-3.5 w-3.5 text-purple-600" />}
<span className={cn(
"text-sm font-medium",
role.name === 'super-admin' ? "text-purple-700" : "text-gray-900"
)}>
{role.display_name}
</span>
</div>
</div>
))}
</div>
```
---
## 8. 頁面佈局規範
### 8.1 頁面結構
```tsx
export default function ResourceIndex() {
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '分類名稱', href: '#' },
{ label: '頁面名稱', href: route('resource.index'), isPage: true },
]}
>
<Head title="頁面標題" />
<div className="container mx-auto p-6 max-w-7xl">
{/* 頁面頭部 */}
{/* 主要內容 */}
{/* 分頁元件 */}
</div>
</AuthenticatedLayout>
);
}
```
### 8.2 標準頁面頭部
```tsx
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<IconComponent className="h-6 w-6 text-[#01ab83]" />
頁面標題
</h1>
<p className="text-gray-500 mt-1">
頁面說明文字
</p>
</div>
<Can permission="resource.create">
<Link href={route('resource.create')}>
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增項目
</Button>
</Link>
</Can>
</div>
```
---
## 9. 權限控制規範
### 9.1 使用 Can 元件
**所有**涉及權限的 UI 元素都必須使用 `<Can>` 元件包裹:
```tsx
import { Can } from "@/Components/Permission/Can";
<Can permission="resource.create">
{/* 新增按鈕 */}
</Can>
<Can permission="resource.edit">
{/* 編輯按鈕 */}
</Can>
<Can permission="resource.delete">
{/* 刪除按鈕 */}
</Can>
```
### 9.2 權限命名規範
遵循 `resource.action` 格式:
- `resource.view`:查看列表/詳情
- `resource.create`:新增
- `resource.edit`:編輯
- `resource.delete`:刪除
### 9.3 多權限判斷
```tsx
// 滿足任一權限即可
<Can permission={['products.edit', 'products.delete']}>
<div>管理操作</div>
</Can>
// 必須滿足所有權限
import { CanAll } from "@/Components/Permission/Can";
<CanAll permissions={['products.edit', 'products.delete']}>
<button>完整管理</button>
</CanAll>
```
---
## 10. 通知訊息規範
### 10.1 使用 Toast 通知
使用 `sonner``toast` 進行通知:
```tsx
import { toast } from 'sonner';
// 成功訊息
toast.success('操作成功');
// 錯誤訊息
toast.error('操作失敗');
// 資訊訊息
toast.info('提示訊息');
// 警告訊息
toast.warning('警告訊息');
```
### 10.2 常見操作的 Toast 訊息
```tsx
// 新增成功
router.post(route('resource.store'), data, {
onSuccess: () => toast.success('新增成功'),
onError: () => toast.error('新增失敗,請檢查輸入內容'),
});
// 更新成功
router.put(route('resource.update', id), data, {
onSuccess: () => toast.success('更新成功'),
onError: () => toast.error('更新失敗'),
});
// 刪除成功
router.delete(route('resource.destroy', id), {
onSuccess: () => toast.success('已刪除'),
onError: () => toast.error('刪除失敗,請檢查權限'),
});
```
---
## 11. 表單規範
### 11.1 表單容器
```tsx
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6">
<form onSubmit={handleSubmit}>
{/* 表單欄位 */}
</form>
</div>
```
### 11.2 表單欄位
```tsx
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
欄位名稱 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={data.field}
onChange={(e) => setData("field", e.target.value)}
placeholder="請輸入..."
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
{errors.field && <p className="mt-1 text-sm text-red-500">{errors.field}</p>}
</div>
```
### 11.3 下拉選單
使用 `SearchableSelect` 元件:
```tsx
import { SearchableSelect } from "@/Components/ui/searchable-select";
<SearchableSelect
value={data.category_id}
onValueChange={(value) => setData("category_id", value)}
options={categories.map(cat => ({ label: cat.name, value: String(cat.id) }))}
placeholder="請選擇分類"
searchThreshold={10} // 超過 10 個選項才顯示搜尋框
/>
```
---
## 11.4 對話框 (Dialog) 滾動與佈局
當對話框內容可能超出螢幕高度時(如長表單或詳細資料),**請勿使用 `ScrollArea`**,應直接在 `DialogContent` 使用原生的 `overflow-y-auto`
**原因**`ScrollArea` 在 Flex 佈局計算高度時容易失效或導致雙重滾動條。以及與原生捲動行為不一致。
```tsx
// ❌ 錯誤:使用 ScrollArea 或固定高度計算
<DialogContent className="max-w-3xl">
<ScrollArea className="h-[500px]">
{/* 內容 */}
</ScrollArea>
</DialogContent>
// ✅ 正確:直接使用 overflow-y-auto 與 max-h
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>...</DialogHeader>
<form className="p-6">
{/* 內容會自動滾動 */}
</form>
<DialogFooter>...</DialogFooter>
</DialogContent>
```
---
## 11.5 輸入框尺寸 (Input Sizes)
為確保介面整齊與統一,所有表單輸入元件標準高度應為 **`h-9`** (36px),與標準按鈕尺寸對齊。
- **Input**: 預設即為 `h-9` (由 `py-1``text-sm` 組合而成)
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。
## 11.6 日期輸入框樣式 (Date Input Style)
日期輸入框應採用「**左側裝飾圖示 + 右側原生操作**」的配置,以保持視覺一致性並保留瀏覽器原生便利性。
**樣式規格**
1. **容器**: 使用 `relative` 定位。
2. **圖標**: 使用 `Calendar` 圖標,放置於絕對位置 `absolute left-2.5 top-2.5`,顏色 `text-gray-400`,並設定 `pointer-events-none` 避免干擾點擊。
3. **輸入框**: 設定 `pl-9` (左內距) 以避開圖示,並使用原生 `type="date"``type="datetime-local"`
```tsx
import { Calendar } from "lucide-react";
import { Input } from "@/Components/ui/input";
<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"
className="pl-9 block w-full"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
```
## 11.7 搜尋選單樣式 (SearchableSelect Style)
`SearchableSelect` 元件在表單或篩選列中使用時,高度必須設定為 `h-9` 以與輸入框對齊。
```tsx
<SearchableSelect
className="h-9" // 確保高度一致
// ...other props
/>
```
## 11.8 篩選列規範 (Filter Bar Norms)
列表頁面的篩選區域Filter Bar應遵循以下規範以節省空間並保持層級清晰
1. **標籤文字 (Labels)**: 使用 **`text-xs`** (`12px`) 大小,顏色建議使用 `text-gray-500``text-grey-2`。這與一般表單 (`text-sm`) 不同,目的是降低篩選列的視覺權重。
2. **輸入元件高度**: 統一使用 **`h-9`** (`36px`)。
3. **佈局**:
- **容器內距**: 統一使用 **`p-5`** (`20px`)。
- **Grid 間距**: 建議使用 **`gap-4`** (`16px`) 或 `gap-6` (`24px`),但同一專案內需統一。本專案推薦 **`gap-4`**。
- **垂直間距**: Label 與 Input 之間使用 **`space-y-1`** (`4px`)。
- **排版**: 建議使用 Grid 系統 (`grid-cols-12`) 進行排版。
```tsx
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2">關鍵字搜尋</Label>
<Input className="h-9" placeholder="..." />
</div>
```
4. **操作按鈕區 (Action Bar)**:
- **位置**: 位於篩選列最下方。
- **樣式**: 統一使用 `flex items-center justify-end border-t border-grey-4 pt-5 gap-3`
- **說明**: `border-grey-4` 為標準通用邊框色,`pt-5` 與容器 padding (`p-5`) 呼應,維持視覺平衡。
5. **收合模式 (Collapsible Mode)**:
- **目的**: 節省垂直空間,預設隱藏較佔空間與低頻使用的篩選器(如日期區間)。
- **實作**:
- 預設狀態:若無相關篩選值,則預設為 **收合 (Collapsed)**
- 切換按鈕:位於 Action Bar 左側 (`mr-auto`)。
- 樣式Ghost Button + `ChevronDown`/`ChevronUp` Icon + 提示圓點 (Indicator)。
- **邏輯**: 若載入頁面時已有被隱藏的篩選值 (e.g. `date_start`),則強制 **展開 (Expanded)** 或顯示提示。
---
## 12. 檢查清單
在開發或審查頁面時,請確認以下項目:
### ✅ 按鈕
- [ ] 使用 `button-filled-*``button-outlined-*` 類別
- [ ] 主要操作使用 `button-filled-primary`
- [ ] 編輯操作使用 `button-outlined-primary`
- [ ] 刪除操作使用 `button-outlined-error`
- [ ] 按鈕尺寸正確sm/default/lg
- [ ] 包含適當的圖標
### ✅ 圖標
- [ ] 全部使用 `lucide-react`
- [ ] 尺寸正確h-3/h-4/h-5/h-6
- [ ] 顏色與上下文一致
### ✅ 表格
- [ ] 使用 `@/Components/ui/table` 元件
- [ ] 有 `bg-white rounded-xl border` 容器
- [ ] 標題列有 `bg-gray-50` 背景
- [ ] 序號欄固定寬度並置中
- [ ] 操作欄使用 `flex justify-center gap-2`
- [ ] 空狀態訊息置中顯示
### ✅ 分頁
- [ ] 使用 `@/Components/shared/Pagination`
- [ ] 有每頁筆數選擇器10/20/50/100
### ✅ 權限
- [ ] 所有操作按鈕都用 `<Can>` 包裹
- [ ] 權限命名符合 `resource.action` 格式
### ✅ 通知
- [ ] 使用 `toast` 提供操作反饋
- [ ] 成功/錯誤訊息明確
### ✅ 整體
- [ ] 頁面有標準頭部(標題 + 圖標 + 說明 + 新增按鈕)
- [ ] 容器寬度使用 `max-w-7xl`
- [ ] 使用正確的佈局(`AuthenticatedLayout`
---
## 13. 常見錯誤與修正
### ❌ 錯誤:自定義按鈕樣式
```tsx
// ❌ 錯誤
<Button className="bg-green-500 text-white hover:bg-green-600">
新增
</Button>
// ✅ 正確
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增
</Button>
```
### ❌ 錯誤:混用圖標庫
```tsx
// ❌ 錯誤
import { FaEdit } from 'react-icons/fa';
<FaEdit />
// ✅ 正確
import { Pencil } from 'lucide-react';
<Pencil className="h-4 w-4" />
```
### ❌ 錯誤:操作欄未置中
```tsx
// ❌ 錯誤
<TableCell>
<Button>編輯</Button>
<Button>刪除</Button>
</TableCell>
// ✅ 正確
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<Button variant="outline" size="sm" className="button-outlined-primary">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" className="button-outlined-error">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
```
### ❌ 錯誤:缺少權限控制
```tsx
// ❌ 錯誤
<Button onClick={handleDelete}>刪除</Button>
// ✅ 正確
<Can permission="resource.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
onClick={handleDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</Can>
```
---
## 14. 參考範例
以下頁面展示了完整的 UI 統一性實踐:
- **使用者管理**`resources/js/Pages/Admin/User/Index.tsx`
- **角色管理**`resources/js/Pages/Admin/Role/Index.tsx`
- **產品管理**`resources/js/Pages/Product/Index.tsx`
- **倉庫管理**`resources/js/Pages/Warehouse/Index.tsx`
---
## 總結
遵循本規範可確保:
1. ✅ **視覺一致性**:所有頁面看起來像同一個系統
2. ✅ **維護效率**:使用統一組件,修改一處即可影響全局
3. ✅ **開發速度**:有明確的模式可循,減少決策時間
4. ✅ **使用者體驗**:一致的互動模式降低學習成本
5. ✅ **安全性**:統一的權限控制確保資料安全
當你在開發或審查 Star ERP 客戶端後台的 UI 時,請務必參考此規範!

View File

@@ -100,7 +100,9 @@ jobs:
npm run build &&
# 3. Laravel 初始化與優化
php artisan storage:link &&
php artisan migrate --force &&
php artisan tenants:migrate --force &&
php artisan db:seed --force &&
php artisan optimize:clear &&
php artisan optimize &&
@@ -130,6 +132,7 @@ jobs:
--exclude='.env' \
--exclude='node_modules' \
--exclude='vendor' \
--exclude='storage' \
--exclude='public/build' \
-e "ssh -p 2224 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \
./ root@erp.koori.tw:/var/www/star-erp/
@@ -169,7 +172,6 @@ jobs:
script: |
cd /var/www/star-erp
chown -R 1000:1000 .
# 檢查是否需要重建
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
@@ -192,7 +194,9 @@ jobs:
npm install &&
npm run build
php artisan storage:link &&
php artisan migrate --force &&
php artisan tenants:migrate --force &&
php artisan optimize:clear &&
php artisan optimize &&
php artisan view:cache

View File

@@ -13,7 +13,7 @@ Star ERP 是一個基於 **Laravel 12**、**Inertia.js (React)** 與 **Tailwind
## 📂 系統功能詳細說明
### 🌳 系統功能架構樹 (含 2.0 升級規劃)
### 🌳 預計系統功能架構樹 (含 2.0 升級規劃)
```text
Star ERP
├── 🏠 儀表板 (Dashboard)

View File

@@ -56,6 +56,11 @@ class TenantController extends Controller
'name' => $validated['name'],
'email' => $validated['email'] ?? null,
'is_active' => true,
'branding' => [
'logo_path' => 'defaults/logo.png', // 預設 Logo 路徑
'login_background_path' => 'defaults/login_bg.jpg', // 預設登入背景
'primary_color' => '#4F46E5', // 預設主色系 (Indigo-600)
],
]);
// 綁定網域(如果沒有輸入,使用預設網域)

View File

@@ -37,8 +37,15 @@ class HandleInertiaRequests extends Middleware
{
$user = $request->user();
$tenant = tenancy()->tenant;
$appName = $tenant ? ($tenant->name ?? 'Star ERP') : 'Star ERP 中央後台';
// 分享給 Blade View (給 app.blade.php 使用)
\Illuminate\Support\Facades\View::share('appName', $appName);
return [
...parent::share($request),
'appName' => $appName,
'auth' => [
'user' => $user ? [
'id' => $user->id,
@@ -58,7 +65,12 @@ class HandleInertiaRequests extends Middleware
'branding' => function () {
$tenant = tenancy()->tenant;
if (!$tenant) {
return null;
// 中央後台預設 Branding
return [
'logo_url' => \Storage::url('defaults/logo.png'), // 中央後台也使用預設 Logo
'primary_color' => '#4F46E5',
'text_color' => '#1a1a1a',
];
}
$logoUrl = null;

View File

@@ -23,6 +23,11 @@ class ActivityLogController extends Controller
'App\Modules\Inventory\Models\Warehouse' => '倉庫',
'App\Modules\Inventory\Models\Inventory' => '庫存',
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
'App\Modules\Inventory\Models\GoodsReceipt' => '進貨單',
'App\Modules\Production\Models\ProductionOrder' => '生產工單',
'App\Modules\Production\Models\Recipe' => '生產配方',
'App\Modules\Production\Models\RecipeItem' => '配方品項',
'App\Modules\Production\Models\ProductionOrderItem' => '工單品項',
];
}

View File

@@ -7,6 +7,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Cookie;
class LoginController extends Controller
{
@@ -44,15 +45,14 @@ class LoginController extends Controller
if (Auth::attempt($credentials, $request->boolean('remember'))) {
$request->session()->regenerate();
$centralDomains = config('tenancy.central_domains', []);
$centralDomains = config('tenancy.central_domains', []);
// [Hack] Demo 環境特殊規則
$demoPort = config('tenancy.demo_tenant_port');
if ((!$demoPort || $request->getPort() != $demoPort) && in_array($request->getHost(), $centralDomains)) {
return redirect()->intended(route('landlord.dashboard'));
return Inertia::location(route('landlord.dashboard'));
}
return redirect()->intended(route('dashboard'));
return Inertia::location(route('dashboard'));
}
throw ValidationException::withMessages([
@@ -71,6 +71,10 @@ class LoginController extends Controller
$request->session()->regenerateToken();
// 強制清除 Session Cookie (對付 HTTPS/Proxy 環境下的殘留問題)
$sessionCookieName = config('session.cookie');
Cookie::queue(Cookie::forget($sessionCookieName));
return redirect('/');
}
}

View File

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

View File

@@ -0,0 +1,210 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\InventoryAdjustDoc;
use App\Modules\Inventory\Models\InventoryCountDoc;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\AdjustService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class AdjustDocController extends Controller
{
protected $adjustService;
public function __construct(AdjustService $adjustService)
{
$this->adjustService = $adjustService;
}
public function index(Request $request)
{
$query = InventoryAdjustDoc::query()
->with(['createdBy', 'postedBy', 'warehouse']);
// 搜尋
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('doc_no', 'like', "%{$search}%")
->orWhere('reason', 'like', "%{$search}%")
->orWhere('remarks', 'like', "%{$search}%");
});
}
if ($request->filled('warehouse_id')) {
$query->where('warehouse_id', $request->warehouse_id);
}
$perPage = $request->input('per_page', 15);
$docs = $query->orderByDesc('created_at')
->paginate($perPage)
->withQueryString()
->through(function ($doc) {
return [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'status' => $doc->status,
'warehouse_name' => $doc->warehouse->name,
'reason' => $doc->reason,
'created_at' => $doc->created_at->format('Y-m-d H:i'),
'posted_at' => $doc->posted_at ? $doc->posted_at->format('Y-m-d H:i') : '-',
'created_by' => $doc->createdBy?->name,
'remarks' => $doc->remarks,
];
});
return Inertia::render('Inventory/Adjust/Index', [
'docs' => $docs,
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
]);
}
public function store(Request $request)
{
// 模式 1: 從盤點單建立
if ($request->filled('count_doc_id')) {
$countDoc = InventoryCountDoc::findOrFail($request->count_doc_id);
// 檢查是否已存在對應的盤調單 (避免重複建立)
if (InventoryAdjustDoc::where('count_doc_id', $countDoc->id)->exists()) {
return redirect()->back()->with('error', '此盤點單已建立過盤調單');
}
$doc = $this->adjustService->createFromCountDoc($countDoc, auth()->id());
return redirect()->route('inventory.adjust.show', [$doc->id])
->with('success', '已從盤點單生成盤調單');
}
// 模式 2: 一般手動調整 (保留原始邏輯但更新訊息)
$validated = $request->validate([
'warehouse_id' => 'required',
'reason' => 'required|string',
'remarks' => 'nullable|string',
]);
$doc = $this->adjustService->createDoc(
$validated['warehouse_id'],
$validated['reason'],
$validated['remarks'],
auth()->id()
);
return redirect()->route('inventory.adjust.show', [$doc->id])
->with('success', '已建立盤調單');
}
/**
* API: 獲取可盤調的已完成盤點單 (支援掃描單號)
*/
public function getPendingCounts(Request $request)
{
$query = InventoryCountDoc::where('status', 'completed')
->whereNotExists(function ($query) {
$query->select(DB::raw(1))
->from('inventory_adjust_docs')
->whereColumn('inventory_adjust_docs.count_doc_id', 'inventory_count_docs.id');
});
if ($request->filled('search')) {
$search = $request->search;
$query->where('doc_no', 'like', "%{$search}%");
}
$counts = $query->limit(10)->get()->map(function($c) {
return [
'id' => (string)$c->id,
'doc_no' => $c->doc_no,
'warehouse_name' => $c->warehouse->name,
'completed_at' => $c->completed_at->format('Y-m-d H:i'),
];
});
return response()->json($counts);
}
public function show(InventoryAdjustDoc $doc)
{
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']);
$docData = [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'warehouse_id' => (string) $doc->warehouse_id,
'warehouse_name' => $doc->warehouse->name,
'status' => $doc->status,
'reason' => $doc->reason,
'remarks' => $doc->remarks,
'created_at' => $doc->created_at->format('Y-m-d H:i'),
'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) {
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,
'unit' => $item->product->baseUnit?->name,
'qty_before' => (float) $item->qty_before,
'adjust_qty' => (float) $item->adjust_qty,
'notes' => $item->notes,
];
}),
];
return Inertia::render('Inventory/Adjust/Show', [
'doc' => $docData,
]);
}
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();
return redirect()->route('inventory.adjust.index')
->with('success', '盤調單已刪除');
}
}

View File

@@ -0,0 +1,215 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\InventoryCountDoc;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\CountService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class CountDocController extends Controller
{
protected $countService;
public function __construct(CountService $countService)
{
$this->countService = $countService;
}
public function index(Request $request)
{
$query = InventoryCountDoc::query()
->with(['createdBy', 'completedBy', 'warehouse']);
if ($request->filled('warehouse_id')) {
$query->where('warehouse_id', $request->warehouse_id);
}
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('doc_no', 'like', "%{$search}%")
->orWhere('remarks', 'like', "%{$search}%");
});
}
$perPage = $request->input('per_page', 10);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = 15;
}
$countQuery = function ($query) {
$query->whereNotNull('counted_qty');
};
$docs = $query->withCount(['items', 'items as counted_items_count' => $countQuery])
->orderByDesc('created_at')
->paginate($perPage)
->withQueryString()
->through(function ($doc) {
return [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'status' => $doc->status,
'warehouse_name' => $doc->warehouse->name,
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : '-',
'completed_at' => $doc->completed_at ? $doc->completed_at->format('Y-m-d H:i') : '-',
'created_by' => $doc->createdBy?->name,
'remarks' => $doc->remarks,
'total_items' => $doc->items_count,
'counted_items' => $doc->counted_items_count,
];
});
return Inertia::render('Inventory/Count/Index', [
'docs' => $docs,
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'warehouse_id' => 'required|exists:warehouses,id',
'remarks' => 'nullable|string|max:255',
]);
$doc = $this->countService->createDoc(
$validated['warehouse_id'],
$validated['remarks'] ?? null,
auth()->id()
);
// 自動執行快照
$this->countService->snapshot($doc);
return redirect()->route('inventory.count.show', [$doc->id])
->with('success', '已建立盤點單並完成庫存快照');
}
public function show(InventoryCountDoc $doc)
{
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
$docData = [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'warehouse_id' => (string) $doc->warehouse_id,
'warehouse_name' => $doc->warehouse->name,
'status' => $doc->status,
'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) {
return [
'id' => (string) $item->id,
'product_name' => $item->product->name,
'product_code' => $item->product->code,
'batch_number' => $item->batch_number,
'unit' => $item->product->baseUnit?->name,
'system_qty' => (float) $item->system_qty,
'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty,
'diff_qty' => (float) $item->diff_qty,
'notes' => $item->notes,
];
}),
];
return Inertia::render('Inventory/Count/Show', [
'doc' => $docData,
]);
}
public function print(InventoryCountDoc $doc)
{
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
$docData = [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'warehouse_name' => $doc->warehouse->name,
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d') : date('Y-m-d'), // Use date only
'created_at' => $doc->created_at->format('Y-m-d'),
'print_date' => date('Y-m-d'),
'created_by' => $doc->createdBy?->name,
'items' => $doc->items->map(function ($item) {
return [
'id' => (string) $item->id,
'product_name' => $item->product->name,
'product_code' => $item->product->code,
'specification' => $item->product->specification,
'unit' => $item->product->baseUnit?->name,
'quantity' => (float) ($item->counted_qty ?? $item->system_qty), // Default to system qty if counted is null, or just counted? User wants "Count Sheet" -> maybe blank if not counted?
// Actually, if it's "Completed", we show counted. If it's "Pending", we usually show blank or system.
// The 'Show' page logic suggests we show counted_qty.
'counted_qty' => $item->counted_qty,
'notes' => $item->notes,
];
}),
];
return Inertia::render('Inventory/Count/Print', [
'doc' => $docData,
]);
}
public function update(Request $request, InventoryCountDoc $doc)
{
if ($doc->status === 'completed') {
return redirect()->back()->with('error', '此盤點單已完成,無法修改');
}
$validated = $request->validate([
'items' => 'array',
'items.*.id' => 'required|exists:inventory_count_items,id',
'items.*.counted_qty' => 'nullable|numeric|min:0',
'items.*.notes' => 'nullable|string',
]);
if (isset($validated['items'])) {
$this->countService->updateCount($doc, $validated['items']);
}
// 如果是按了 "完成盤點"
if ($request->input('action') === 'complete') {
$this->countService->complete($doc, auth()->id());
return redirect()->route('inventory.count.index')
->with('success', '盤點單已完成');
}
return redirect()->back()->with('success', '暫存成功');
}
public function reopen(InventoryCountDoc $doc)
{
if ($doc->status !== 'completed') {
return redirect()->back()->with('error', '只有已核准的盤點單可以取消核准');
}
// TODO: Move logic to Service if complex
$doc->update([
'status' => 'counting', // Revert to counting (draft)
'completed_at' => null,
'completed_by' => null,
]);
return redirect()->route('inventory.count.show', [$doc->id])
->with('success', '已取消核准,單據回復為盤點中狀態');
}
public function destroy(InventoryCountDoc $doc)
{
if ($doc->status === 'completed') {
return redirect()->back()->with('error', '已完成的盤點單無法刪除');
}
$doc->items()->delete();
$doc->delete();
return redirect()->route('inventory.count.index')
->with('success', '盤點單已刪除');
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Services\GoodsReceiptService;
use App\Modules\Inventory\Services\InventoryService;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Http\Request;
use App\Modules\Procurement\Models\Vendor;
use Inertia\Inertia;
use App\Modules\Inventory\Models\GoodsReceipt;
class GoodsReceiptController extends Controller
{
protected $goodsReceiptService;
protected $inventoryService;
protected $procurementService;
public function __construct(
GoodsReceiptService $goodsReceiptService,
InventoryService $inventoryService,
ProcurementServiceInterface $procurementService
) {
$this->goodsReceiptService = $goodsReceiptService;
$this->inventoryService = $inventoryService;
$this->procurementService = $procurementService;
}
public function index(Request $request)
{
$query = GoodsReceipt::query()
->select(['id', 'code', 'type', 'warehouse_id', 'vendor_id', 'received_date', 'status', 'created_at'])
->with(['warehouse'])
->withSum('items', 'total_amount');
// 關鍵字搜尋(單號)
if ($request->filled('search')) {
$search = $request->input('search');
$query->where('code', 'like', "%{$search}%");
}
// 狀態篩選
if ($request->filled('status') && $request->input('status') !== 'all') {
$query->where('status', $request->input('status'));
}
// 倉庫篩選
if ($request->filled('warehouse_id') && $request->input('warehouse_id') !== 'all') {
$query->where('warehouse_id', $request->input('warehouse_id'));
}
// 日期範圍篩選
if ($request->filled('date_start')) {
$query->whereDate('received_date', '>=', $request->input('date_start'));
}
if ($request->filled('date_end')) {
$query->whereDate('received_date', '<=', $request->input('date_end'));
}
// 每頁筆數
$perPage = $request->input('per_page', 10);
$receipts = $query->orderBy('created_at', 'desc')
->paginate($perPage)
->withQueryString();
// Manual Hydration for Vendors (Cross-Module)
$vendorIds = collect($receipts->items())->pluck('vendor_id')->unique()->filter()->toArray();
$vendors = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id');
$receipts->getCollection()->transform(function ($receipt) use ($vendors) {
$receipt->vendor = $vendors->get($receipt->vendor_id);
return $receipt;
});
// 取得倉庫列表用於篩選
$warehouses = $this->inventoryService->getAllWarehouses();
return Inertia::render('Inventory/GoodsReceipt/Index', [
'receipts' => $receipts,
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'per_page']),
'warehouses' => $warehouses,
]);
}
public function show($id)
{
$receipt = GoodsReceipt::with([
'warehouse',
'items.product.category',
'items.product.baseUnit'
])->findOrFail($id);
// Manual Hydration for Vendor (Cross-Module)
if ($receipt->vendor_id) {
$receipt->vendor = $this->procurementService->getVendorsByIds([$receipt->vendor_id])->first();
}
// 手動計算統計資訊 (如果 Model 沒有定義對應的 Attribute)
$receipt->items_sum_total_amount = $receipt->items->sum('total_amount');
return Inertia::render('Inventory/GoodsReceipt/Show', [
'receipt' => $receipt
]);
}
public function create()
{
// 取得待進貨的採購單列表(用於標準採購類型選擇)
$pendingPOs = $this->procurementService->getPendingPurchaseOrders();
// 提取所有產品 ID 以便跨模組水和資料
$productIds = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
// 處理採購單資料,計算剩餘可收貨數量
$formattedPOs = $pendingPOs->map(function ($po) use ($products) {
return [
'id' => $po->id,
'code' => $po->code,
'status' => $po->status,
'vendor_id' => $po->vendor_id,
'vendor_name' => $po->vendor?->name ?? '',
'warehouse_id' => $po->warehouse_id,
'order_date' => $po->order_date,
'items' => $po->items->map(function ($item) use ($products) {
$product = $products->get($item->product_id);
$remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
return [
'id' => $item->id,
'product_id' => $item->product_id,
'product_name' => $product?->name ?? '',
'product_code' => $product?->code ?? '',
'unit' => $product?->baseUnit?->name ?? '個',
'quantity' => $item->quantity,
'received_quantity' => $item->received_quantity ?? 0,
'remaining' => $remaining,
'unit_price' => $item->unit_price,
];
})->filter(fn($item) => $item['remaining'] > 0)->values(),
];
})->filter(fn($po) => $po['items']->count() > 0)->values();
// 取得所有廠商列表(用於雜項入庫/其他類型選擇)
$vendors = $this->procurementService->getAllVendors();
return Inertia::render('Inventory/GoodsReceipt/Create', [
'warehouses' => $this->inventoryService->getAllWarehouses(),
'pendingPurchaseOrders' => $formattedPOs,
'vendors' => $vendors,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'warehouse_id' => 'required|exists:warehouses,id',
'type' => 'required|in:standard,miscellaneous,other',
'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id',
// Vendor ID is required if standard, but optional/nullable for misc/other?
// Stick to existing logic: if standard, we infer vendor from PO usually, or frontend sends it.
// For now let's make vendor_id optional for misc/other or user must select one?
// "雜項入庫" might not have a vendor. Let's make it nullable.
'vendor_id' => 'nullable|integer',
'received_date' => 'required|date',
'remarks' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|integer|exists:products,id',
'items.*.purchase_order_item_id' => 'nullable|required_if:type,standard|integer',
'items.*.quantity_received' => 'required|numeric|min:0',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.batch_number' => 'nullable|string',
'items.*.expiry_date' => 'nullable|date',
]);
$this->goodsReceiptService->store($validated);
return redirect()->route('goods-receipts.index')->with('success', '進貨單已建立');
}
// API to search POs
public function searchPOs(Request $request)
{
$search = $request->input('query');
if (!$search) {
return response()->json([]);
}
$pos = $this->procurementService->searchPendingPurchaseOrders($search);
return response()->json($pos);
}
// API to search Products for Manual Entry
public function searchProducts(Request $request)
{
$search = $request->input('query');
if (!$search) {
return response()->json([]);
}
$products = $this->inventoryService->getProductsByName($search);
// Format for frontend
$mapped = $products->map(function($product) {
return [
'id' => $product->id,
'name' => $product->name,
'code' => $product->code,
'unit' => $product->baseUnit?->name ?? '個', // Ensure unit is included
'price' => $product->purchase_price ?? 0, // Suggest price from product info if available
];
});
return response()->json($mapped);
}
// API to search Vendors
public function searchVendors(Request $request)
{
$search = $request->input('query');
if (!$search) {
return response()->json([]);
}
$vendors = $this->procurementService->searchVendors($search);
return response()->json($vendors);
}
/**
* 刪除進貨單
*/
public function destroy(GoodsReceipt $goodsReceipt)
{
// 只有有權限的人可以刪除
if (!auth()->user()->can('goods_receipts.delete')) {
return redirect()->back()->with('error', '您沒有權限刪除進貨單');
}
// 簡單刪除邏輯:刪除進貨單(品項由資料庫級聯刪除或手動處理)
// 注意:實務上可能需要處理已入庫的庫存回滾,但在這個簡易 ERP 中通常是行政刪除
$goodsReceipt->delete();
return redirect()->route('goods-receipts.index')->with('success', '進貨單已刪除');
}
}

View File

@@ -10,6 +10,7 @@ use Inertia\Inertia;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryTransaction;
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
use App\Modules\Core\Contracts\CoreServiceInterface;
@@ -482,7 +483,60 @@ class InventoryController extends Controller
$productId = $request->query('productId');
if ($productId) {
// ... (略) ...
$product = Product::findOrFail($productId);
// 取得該倉庫中該商品的所有批號 ID
$inventoryIds = Inventory::where('warehouse_id', $warehouse->id)
->where('product_id', $productId)
->pluck('id')
->toArray();
$transactionsRaw = InventoryTransaction::whereIn('inventory_id', $inventoryIds)
->with('inventory') // 需要批號資訊
->orderBy('actual_time', 'desc')
->orderBy('id', 'desc')
->get();
// 手動 Hydrate 使用者資料
$userIds = $transactionsRaw->pluck('user_id')->filter()->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
// 計算商品在該倉庫的總量(不分批號)
$currentRunningTotal = (float) Inventory::whereIn('id', $inventoryIds)->sum('quantity');
$transactions = $transactionsRaw->map(function ($tx) use ($users, &$currentRunningTotal) {
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
$balanceAfter = $currentRunningTotal;
// 為下一筆(較舊的)紀錄更新 Running Total
$currentRunningTotal -= (float) $tx->quantity;
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'unit_cost' => (float) $tx->unit_cost,
'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘
'reason' => $tx->reason,
'userName' => $user ? $user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
];
});
// 重新計算目前的總量(用於 Header 顯示,確保一致性)
$totalQuantity = Inventory::whereIn('id', $inventoryIds)->sum('quantity');
return Inertia::render('Warehouse/InventoryHistory', [
'warehouse' => $warehouse,
'inventory' => [
'id' => null, // 跨批號查詢沒有單一 ID
'productName' => $product->name,
'productCode' => $product->code,
'batchNumber' => '所有批號',
'quantity' => (float) $totalQuantity,
],
'transactions' => $transactions
]);
}
if ($inventoryId) {

View File

@@ -25,6 +25,7 @@ class ProductController extends Controller
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('barcode', 'like', "%{$search}%")
->orWhere('brand', 'like', "%{$search}%");
});
}
@@ -66,6 +67,7 @@ class ProductController extends Controller
return (object) [
'id' => (string) $product->id,
'code' => $product->code,
'barcode' => $product->barcode,
'name' => $product->name,
'categoryId' => $product->category_id,
'category' => $product->category ? (object) [
@@ -110,6 +112,7 @@ class ProductController extends Controller
{
$validated = $request->validate([
'code' => 'required|string|max:2|unique:products,code',
'barcode' => 'required|string|unique:products,barcode',
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
@@ -123,6 +126,8 @@ class ProductController extends Controller
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 2 碼',
'code.unique' => '商品代號已存在',
'barcode.required' => '條碼編號為必填',
'barcode.unique' => '條碼編號已存在',
'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在',
@@ -145,6 +150,7 @@ class ProductController extends Controller
{
$validated = $request->validate([
'code' => 'required|string|max:2|unique:products,code,' . $product->id,
'barcode' => 'required|string|unique:products,barcode,' . $product->id,
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
@@ -157,6 +163,8 @@ class ProductController extends Controller
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 2 碼',
'code.unique' => '商品代號已存在',
'barcode.required' => '條碼編號為必填',
'barcode.unique' => '條碼編號已存在',
'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在',

View File

@@ -3,135 +3,171 @@
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryTransferOrder;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\TransferService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
class TransferOrderController extends Controller
{
/**
* 儲存撥補單(建立調撥單並執行庫存轉移)
*/
protected $transferService;
public function __construct(TransferService $transferService)
{
$this->transferService = $transferService;
}
public function index(Request $request)
{
$query = InventoryTransferOrder::query()
->with(['fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
// 篩選:若有選定倉庫,則顯示該倉庫作為來源或目的地的調撥單
if ($request->filled('warehouse_id')) {
$query->where(function ($q) use ($request) {
$q->where('from_warehouse_id', $request->warehouse_id)
->orWhere('to_warehouse_id', $request->warehouse_id);
});
}
$orders = $query->orderByDesc('created_at')
->paginate(15)
->through(function ($order) {
return [
'id' => (string) $order->id,
'doc_no' => $order->doc_no,
'from_warehouse_name' => $order->fromWarehouse->name,
'to_warehouse_name' => $order->toWarehouse->name,
'status' => $order->status,
'created_at' => $order->created_at->format('Y-m-d H:i'),
'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i') : '-',
'created_by' => $order->createdBy?->name,
];
});
return Inertia::render('Inventory/Transfer/Index', [
'orders' => $orders,
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
'filters' => $request->only(['warehouse_id']),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'sourceWarehouseId' => 'required|exists:warehouses,id',
'targetWarehouseId' => 'required|exists:warehouses,id|different:sourceWarehouseId',
'productId' => 'required|exists:products,id',
'quantity' => 'required|numeric|min:0.01',
'transferDate' => 'required|date',
'status' => 'required|in:待處理,處理中,已完成,已取消', // 目前僅支援立即完成或單純記錄
'notes' => 'nullable|string',
'batchNumber' => 'nullable|string', // 暫時接收,雖然 DB 可能沒存
'from_warehouse_id' => 'required|exists:warehouses,id',
'to_warehouse_id' => 'required|exists:warehouses,id|different:from_warehouse_id',
'remarks' => 'nullable|string',
]);
return DB::transaction(function () use ($validated) {
// 1. 檢查來源倉庫庫存 (精確匹配產品與批號)
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
->where('product_id', $validated['productId'])
->where('batch_number', $validated['batchNumber'])
->first();
$order = $this->transferService->createOrder(
$validated['from_warehouse_id'],
$validated['to_warehouse_id'],
$validated['remarks'] ?? null,
auth()->id()
);
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
throw ValidationException::withMessages([
'quantity' => ['來源倉庫指定批號庫存不足'],
]);
return redirect()->route('inventory.transfer.show', [$order->id])
->with('success', '已建立調撥單');
}
public function show(InventoryTransferOrder $order)
{
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
$orderData = [
'id' => (string) $order->id,
'doc_no' => $order->doc_no,
'from_warehouse_id' => (string) $order->from_warehouse_id,
'from_warehouse_name' => $order->fromWarehouse->name,
'to_warehouse_id' => (string) $order->to_warehouse_id,
'to_warehouse_name' => $order->toWarehouse->name,
'status' => $order->status,
'remarks' => $order->remarks,
'created_at' => $order->created_at->format('Y-m-d H:i'),
'created_by' => $order->createdBy?->name,
'items' => $order->items->map(function ($item) {
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,
'unit' => $item->product->baseUnit?->name,
'quantity' => (float) $item->quantity,
'notes' => $item->notes,
];
}),
];
return Inertia::render('Inventory/Transfer/Show', [
'order' => $orderData,
]);
}
public function update(Request $request, InventoryTransferOrder $order)
{
if ($order->status !== 'draft') {
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
}
if ($request->input('action') === 'post') {
try {
$this->transferService->post($order, auth()->id());
return redirect()->route('inventory.transfer.index')
->with('success', '調撥單已過帳完成');
} catch (\Exception $e) {
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
}
}
// 2. 獲取或建立目標倉庫庫存 (精確匹配產品與批號,並繼承效期與品質狀態)
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $validated['targetWarehouseId'],
'product_id' => $validated['productId'],
'batch_number' => $validated['batchNumber'],
],
[
'quantity' => 0,
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
'total_value' => 0,
'expiry_date' => $sourceInventory->expiry_date,
'quality_status' => $sourceInventory->quality_status,
'origin_country' => $sourceInventory->origin_country,
]
);
$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',
]);
$sourceWarehouse = Warehouse::find($validated['sourceWarehouseId']);
$targetWarehouse = Warehouse::find($validated['targetWarehouseId']);
if ($request->has('items')) {
$this->transferService->updateItems($order, $validated['items']);
}
// 3. 執行庫存轉移 (扣除來源)
$oldSourceQty = $sourceInventory->quantity;
$newSourceQty = $oldSourceQty - $validated['quantity'];
$order->update($request->only(['remarks']));
// 設定活動紀錄原因
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
$sourceInventory->quantity = $newSourceQty;
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; // 更新總值
$sourceInventory->save();
return redirect()->back()->with('success', '儲存成功');
}
// 記錄來源異動
$sourceInventory->transactions()->create([
'type' => '撥補出庫',
'quantity' => -$validated['quantity'],
'unit_cost' => $sourceInventory->unit_cost, // 記錄
'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty,
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
'actual_time' => $validated['transferDate'],
'user_id' => auth()->id(),
]);
public function destroy(InventoryTransferOrder $order)
{
if ($order->status !== 'draft') {
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
}
$order->items()->delete();
$order->delete();
// 4. 執行庫存轉移 (增加目標)
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $validated['quantity'];
// 設定活動紀錄原因
$targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}";
// 確保目標庫存也有成本 (如果是繼承來的)
if ($targetInventory->unit_cost == 0 && $sourceInventory->unit_cost > 0) {
$targetInventory->unit_cost = $sourceInventory->unit_cost;
}
$targetInventory->quantity = $newTargetQty;
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; // 更新總值
$targetInventory->save();
// 記錄目標異動
$targetInventory->transactions()->create([
'type' => '撥補入庫',
'quantity' => $validated['quantity'],
'unit_cost' => $targetInventory->unit_cost, // 記錄
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
'actual_time' => $validated['transferDate'],
'user_id' => auth()->id(),
]);
// TODO: 未來若有獨立的 TransferOrder 模型,可在此建立紀錄
return redirect()->back()->with('success', '撥補單已建立且庫存已轉移');
});
return redirect()->route('inventory.transfer.index')
->with('success', '調撥單已刪除');
}
/**
* 獲取特定倉庫的庫存列表 (API)
* 獲取特定倉庫的庫存列表 (API) - 保留給前端選擇商品用
*/
public function getWarehouseInventories(Warehouse $warehouse)
{
$inventories = $warehouse->inventories()
->with(['product.baseUnit', 'product.category'])
->where('quantity', '>', 0) // 只回傳有庫存的
->where('quantity', '>', 0)
->get()
->map(function ($inv) {
return [
'product_id' => (string) $inv->product_id,
'product_name' => $inv->product->name,
'product_code' => $inv->product->code, // Added code
'batch_number' => $inv->batch_number,
'quantity' => (float) $inv->quantity,
'unit_cost' => (float) $inv->unit_cost, // 新增
'total_value' => (float) $inv->total_value, // 新增
'unit_cost' => (float) $inv->unit_cost,
'unit_name' => $inv->product->baseUnit?->name ?? '個',
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
];

View File

@@ -26,9 +26,12 @@ class WarehouseController extends Controller
$warehouses = $query->withSum('inventories as book_stock', 'quantity') // 帳面庫存 = 所有庫存總和
->withSum(['inventories as available_stock' => function ($query) {
// 可用庫存 = 庫存 > 0 且 品質正常 且 (未過期 或 無效期)
// 可用庫存 = 庫存 > 0 且 品質正常 且 (未過期 或 無效期) 且 倉庫類型不為瑕疵倉
$query->where('quantity', '>', 0)
->where('quality_status', 'normal')
->whereHas('warehouse', function ($q) {
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
})
->where(function ($q) {
$q->whereNull('expiry_date')
->orWhere('expiry_date', '>=', now());
@@ -38,20 +41,15 @@ class WarehouseController extends Controller
->paginate(10)
->withQueryString();
// 修正各倉庫列表中的可用庫存計算:若倉庫不可銷售,則可用庫存為 0
$warehouses->getCollection()->transform(function ($w) {
if (!$w->is_sellable) {
$w->available_stock = 0;
}
return $w;
});
// 移除原本對 is_sellable 的手動修正邏輯,現在由 type 自動過濾
// 計算全域總計 (不分頁)
$totals = [
'available_stock' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
->where('quality_status', 'normal')
->whereHas('warehouse', function ($q) {
$q->where('is_sellable', true);
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
})
->where(function ($q) {
$q->whereNull('expiry_date')
@@ -73,7 +71,6 @@ class WarehouseController extends Controller
'name' => 'required|string|max:50',
'address' => 'nullable|string|max:255',
'description' => 'nullable|string',
'is_sellable' => 'nullable|boolean',
'type' => 'required|string',
'license_plate' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
@@ -98,7 +95,6 @@ class WarehouseController extends Controller
'name' => 'required|string|max:50',
'address' => 'nullable|string|max:255',
'description' => 'nullable|string',
'is_sellable' => 'nullable|boolean',
'type' => 'required|string',
'license_plate' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class GoodsReceipt extends Model
{
use HasFactory, SoftDeletes;
use \Spatie\Activitylog\Traits\LogsActivity;
protected $fillable = [
'code',
'type',
'warehouse_id',
'purchase_order_id',
'vendor_id',
'received_date',
'status',
'remarks',
'user_id',
];
protected $casts = [
'received_date' => 'date:Y-m-d',
];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
{
return \Spatie\Activitylog\LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function items()
{
return $this->hasMany(GoodsReceiptItem::class);
}
// Strict Mode: relationships to Warehouse is allowed (same module).
public function warehouse()
{
return $this->belongsTo(Warehouse::class);
}
// Strict Mode: cross-module relationship to Vendor/User/PurchaseOrder is restricted.
// They are accessed via IDs or Services.
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class GoodsReceiptItem extends Model
{
use HasFactory;
protected $fillable = [
'goods_receipt_id',
'product_id',
'purchase_order_item_id',
'quantity_received',
'unit_price',
'total_amount',
'batch_number',
'expiry_date',
];
protected $casts = [
'quantity_received' => 'decimal:2',
'unit_price' => 'decimal:2', // 暫定價格
'total_amount' => 'decimal:2',
'expiry_date' => 'date:Y-m-d',
];
public function goodsReceipt()
{
return $this->belongsTo(GoodsReceipt::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,81 @@
<?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 App\Modules\Core\Models\User;
class InventoryAdjustDoc extends Model
{
use HasFactory;
protected $fillable = [
'doc_no',
'count_doc_id',
'warehouse_id',
'status',
'reason',
'remarks',
'posted_at',
'created_by',
'updated_by',
'posted_by',
];
protected $casts = [
'posted_at' => 'datetime',
];
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->doc_no)) {
$today = date('Ymd');
$prefix = 'ADJ' . $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;
}
});
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function countDoc(): BelongsTo
{
return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id');
}
public function items(): HasMany
{
return $this->hasMany(InventoryAdjustItem::class, 'adjust_doc_id');
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function postedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'posted_by');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InventoryAdjustItem extends Model
{
use HasFactory;
protected $fillable = [
'adjust_doc_id',
'product_id',
'batch_number',
'qty_before',
'adjust_qty', // 增減數量
'notes',
];
protected $casts = [
'qty_before' => 'decimal:2',
'adjust_qty' => 'decimal:2',
];
public function doc(): BelongsTo
{
return $this->belongsTo(InventoryAdjustDoc::class, 'adjust_doc_id');
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,78 @@
<?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 App\Modules\Core\Models\User;
class InventoryCountDoc extends Model
{
use HasFactory;
protected $fillable = [
'doc_no',
'warehouse_id',
'status',
'snapshot_date',
'completed_at',
'remarks',
'created_by',
'updated_by',
'completed_by',
];
protected $casts = [
'snapshot_date' => 'datetime',
'completed_at' => 'datetime',
];
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->doc_no)) {
$today = date('Ymd');
$prefix = 'CNT' . $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;
}
});
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function items(): HasMany
{
return $this->hasMany(InventoryCountItem::class, 'count_doc_id');
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function completedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'completed_by');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InventoryCountItem extends Model
{
use HasFactory;
protected $fillable = [
'count_doc_id',
'product_id',
'batch_number',
'system_qty',
'counted_qty',
'diff_qty',
'notes',
];
protected $casts = [
'system_qty' => 'decimal:2',
'counted_qty' => 'decimal:2',
'diff_qty' => 'decimal:2',
];
public function doc(): BelongsTo
{
return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id');
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InventoryTransferItem extends Model
{
use HasFactory;
protected $fillable = [
'transfer_order_id',
'product_id',
'batch_number',
'quantity',
'notes',
];
protected $casts = [
'quantity' => 'decimal:2',
];
public function order(): BelongsTo
{
return $this->belongsTo(InventoryTransferOrder::class, 'transfer_order_id');
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,66 @@
<?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 App\Modules\Core\Models\User;
class InventoryTransferOrder extends Model
{
use HasFactory;
protected $fillable = [
'doc_no',
'from_warehouse_id',
'to_warehouse_id',
'status',
'remarks',
'posted_at',
'created_by',
'updated_by',
'posted_by',
];
protected $casts = [
'posted_at' => 'datetime',
];
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->doc_no)) {
$model->doc_no = 'TRF-' . date('YmdHis') . '-' . rand(100, 999);
}
});
}
public function fromWarehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'from_warehouse_id');
}
public function toWarehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'to_warehouse_id');
}
public function items(): HasMany
{
return $this->hasMany(InventoryTransferItem::class, 'transfer_order_id');
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function postedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'posted_by');
}
}

View File

@@ -17,6 +17,7 @@ class Product extends Model
protected $fillable = [
'code',
'barcode',
'name',
'category_id',
'brand',

View File

@@ -18,13 +18,11 @@ class Warehouse extends Model
'type',
'address',
'description',
'is_sellable',
'license_plate',
'driver_name',
];
protected $casts = [
'is_sellable' => 'boolean',
'type' => \App\Enums\WarehouseType::class,
];

View File

@@ -8,6 +8,8 @@ use App\Modules\Inventory\Controllers\WarehouseController;
use App\Modules\Inventory\Controllers\InventoryController;
use App\Modules\Inventory\Controllers\SafetyStockController;
use App\Modules\Inventory\Controllers\TransferOrderController;
use App\Modules\Inventory\Controllers\CountDocController;
use App\Modules\Inventory\Controllers\AdjustDocController;
Route::middleware('auth')->group(function () {
@@ -54,7 +56,7 @@ Route::middleware('auth')->group(function () {
Route::delete('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'destroy'])->name('warehouses.inventory.destroy');
});
// API: 取得商品在特定倉庫的所有批號
// API: 取得商品在特定倉庫的所有批號
Route::get('/api/warehouses/{warehouse}/inventory/batches/{productId}', [InventoryController::class, 'getBatches'])
->name('api.warehouses.inventory.batches');
});
@@ -70,11 +72,50 @@ Route::middleware('auth')->group(function () {
});
});
// 撥補單 (在庫存調撥時使用)
// 庫存盤點 (Stock Counting) - Global
Route::middleware('permission:inventory.view')->group(function () {
Route::get('/inventory/count-docs', [CountDocController::class, 'index'])->name('inventory.count.index');
Route::get('/inventory/count-docs/{doc}', [CountDocController::class, 'show'])->name('inventory.count.show');
Route::middleware('permission:inventory.adjust')->group(function () {
Route::post('/inventory/count-docs', [CountDocController::class, 'store'])->name('inventory.count.store');
Route::put('/inventory/count-docs/{doc}', [CountDocController::class, 'update'])->name('inventory.count.update');
Route::delete('/inventory/count-docs/{doc}', [CountDocController::class, 'destroy'])->name('inventory.count.destroy');
Route::put('/inventory/count-docs/{doc}/reopen', [CountDocController::class, 'reopen'])->name('inventory.count.reopen');
});
Route::get('/inventory/count-docs/{doc}/print', [CountDocController::class, 'print'])->name('inventory.count.print');
});
// 庫存盤調 (Stock Adjustment) - Global
Route::middleware('permission:inventory.adjust')->group(function () {
Route::get('/inventory/adjust-docs', [AdjustDocController::class, 'index'])->name('inventory.adjust.index');
Route::get('/inventory/adjust-docs/get-pending-counts', [AdjustDocController::class, 'getPendingCounts'])->name('inventory.adjust.pending-counts');
Route::get('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'show'])->name('inventory.adjust.show');
Route::post('/inventory/adjust-docs', [AdjustDocController::class, 'store'])->name('inventory.adjust.store');
Route::put('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'update'])->name('inventory.adjust.update');
Route::delete('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'destroy'])->name('inventory.adjust.destroy');
});
// 撥補單/調撥單 (Transfer Order) - Global
Route::middleware('permission:inventory.transfer')->group(function () {
Route::post('/transfer-orders', [TransferOrderController::class, 'store'])->name('transfer-orders.store');
Route::get('/inventory/transfer-orders', [TransferOrderController::class, 'index'])->name('inventory.transfer.index');
Route::get('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'show'])->name('inventory.transfer.show');
Route::post('/inventory/transfer-orders', [TransferOrderController::class, 'store'])->name('inventory.transfer.store');
Route::put('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'update'])->name('inventory.transfer.update');
Route::delete('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'destroy'])->name('inventory.transfer.destroy');
});
Route::get('/api/warehouses/{warehouse}/inventories', [TransferOrderController::class, 'getWarehouseInventories'])
->middleware('permission:inventory.view')
->name('api.warehouses.inventories');
// 進貨單 (Goods Receipts)
Route::middleware('permission:goods_receipts.view')->group(function () {
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');
Route::get('/goods-receipts/create', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'create'])->middleware('permission:goods_receipts.create')->name('goods-receipts.create');
Route::get('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'show'])->name('goods-receipts.show');
Route::post('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'store'])->middleware('permission:goods_receipts.create')->name('goods-receipts.store');
Route::get('/api/goods-receipts/search-pos', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchPOs'])->name('goods-receipts.search-pos');
Route::get('/api/goods-receipts/search-products', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchProducts'])->name('goods-receipts.search-products');
Route::get('/api/goods-receipts/search-vendors', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchVendors'])->name('goods-receipts.search-vendors');
});
});

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryCountDoc;
use App\Modules\Inventory\Models\InventoryAdjustDoc;
use App\Modules\Inventory\Models\InventoryAdjustItem;
use Illuminate\Support\Facades\DB;
class AdjustService
{
public function createDoc(string $warehouseId, string $reason, ?string $remarks = null, int $userId, ?int $countDocId = null): InventoryAdjustDoc
{
return InventoryAdjustDoc::create([
'warehouse_id' => $warehouseId,
'count_doc_id' => $countDocId,
'status' => 'draft',
'reason' => $reason,
'remarks' => $remarks,
'created_by' => $userId,
]);
}
/**
* 從盤點單建立盤調單
*/
public function createFromCountDoc(InventoryCountDoc $countDoc, int $userId): InventoryAdjustDoc
{
return DB::transaction(function () use ($countDoc, $userId) {
// 1. 建立盤調單頭
$adjDoc = $this->createDoc(
$countDoc->warehouse_id,
"盤點調整: " . $countDoc->doc_no,
"由盤點單 {$countDoc->doc_no} 自動生成",
$userId,
$countDoc->id
);
// 2. 抓取有差異的明細 (diff_qty != 0)
foreach ($countDoc->items as $item) {
if (abs($item->diff_qty) < 0.0001) continue;
$adjDoc->items()->create([
'product_id' => $item->product_id,
'batch_number' => $item->batch_number,
'qty_before' => $item->system_qty,
'adjust_qty' => $item->diff_qty,
'notes' => "盤點差異: " . $item->diff_qty,
]);
}
return $adjDoc;
});
}
/**
* 更新盤調單內容 (Items)
* 此處採用 "全量更新" 方式處理 items (先刪後加),簡單可靠
*/
public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void
{
DB::transaction(function () use ($doc, $itemsData) {
$doc->items()->delete();
foreach ($itemsData as $data) {
// 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準)
$inventory = Inventory::where('warehouse_id', $doc->warehouse_id)
->where('product_id', $data['product_id'])
->where('batch_number', $data['batch_number'] ?? null)
->first();
$qtyBefore = $inventory ? $inventory->quantity : 0;
$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,
]);
}
});
}
/**
* 過帳 (Post) - 生效庫存異動
*/
public function post(InventoryAdjustDoc $doc, int $userId): void
{
DB::transaction(function () use ($doc, $userId) {
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,
'batch_number' => $item->batch_number,
]);
// 如果是新建立的 object (id 為空),需要初始化 default
if (!$inventory->exists) {
// 繼承 Product 成本或預設 0 (簡化處理)
$inventory->unit_cost = $item->product->cost ?? 0;
$inventory->quantity = 0;
}
$oldQty = $inventory->quantity;
$newQty = $oldQty + $item->adjust_qty;
$inventory->quantity = $newQty;
// 用最新的數量 * 單位成本 (簡化成本計算,不採用移動加權)
$inventory->total_value = $newQty * $inventory->unit_cost;
$inventory->save();
// 建立 Transaction
$inventory->transactions()->create([
'type' => '庫存調整',
'quantity' => $item->adjust_qty,
'unit_cost' => $inventory->unit_cost,
'balance_before' => $oldQty,
'balance_after' => $newQty,
'reason' => "盤調單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
'actual_time' => now(),
'user_id' => $userId,
]);
}
$doc->update([
'status' => 'posted',
'posted_at' => now(),
'posted_by' => $userId,
]);
// 4. 若關聯盤點單,連動更新盤點單狀態
if ($doc->count_doc_id) {
InventoryCountDoc::where('id', $doc->count_doc_id)->update([
'status' => 'adjusted'
]);
}
});
}
/**
* 作廢 (Void)
*/
public function void(InventoryAdjustDoc $doc, int $userId): void
{
if ($doc->status !== 'draft') {
throw new \Exception('只能作廢草稿狀態的單據');
}
$doc->update([
'status' => 'voided',
'updated_by' => $userId
]);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryCountDoc;
use App\Modules\Inventory\Models\InventoryCountItem;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class CountService
{
/**
* 建立新的盤點單並執行快照
*/
public function createDoc(string $warehouseId, string $remarks = null, int $userId): InventoryCountDoc
{
return DB::transaction(function () use ($warehouseId, $remarks, $userId) {
$doc = InventoryCountDoc::create([
'warehouse_id' => $warehouseId,
'status' => 'draft',
'remarks' => $remarks,
'created_by' => $userId,
]);
return $doc;
});
}
/**
* 執行快照:鎖定當前庫存量
*/
public function snapshot(InventoryCountDoc $doc): void
{
DB::transaction(function () use ($doc) {
// 清除舊的 items (如果有)
$doc->items()->delete();
// 取得該倉庫所有庫存 (包含 quantity = 0 但未軟刪除的)
// 這裡可以根據需求決定是否要過濾掉 0 庫存,通常盤點單會希望能看到所有 "帳上有紀錄" 的東西
$inventories = Inventory::where('warehouse_id', $doc->warehouse_id)
->whereNull('deleted_at')
->get();
$items = [];
foreach ($inventories as $inv) {
$items[] = [
'count_doc_id' => $doc->id,
'product_id' => $inv->product_id,
'batch_number' => $inv->batch_number,
'system_qty' => $inv->quantity,
'counted_qty' => null, // 預設未盤點
'diff_qty' => 0,
'created_at' => now(),
'updated_at' => now(),
];
}
if (!empty($items)) {
InventoryCountItem::insert($items);
}
$doc->update([
'status' => 'counting',
'snapshot_date' => now(),
]);
});
}
/**
* 完成盤點:過帳差異
*/
public function complete(InventoryCountDoc $doc, int $userId): void
{
DB::transaction(function () use ($doc, $userId) {
// 僅更新單據狀態為「已完成」,不執行庫存入庫/調整
// 盤點單僅作為記錄,後續調整由盤調單 (AdjustDoc) 執行
$doc->update([
'status' => 'completed',
'completed_at' => now(),
'completed_by' => $userId,
]);
});
}
/**
* 更新盤點數量
*/
public function updateCount(InventoryCountDoc $doc, array $itemsData): void
{
DB::transaction(function () use ($doc, $itemsData) {
foreach ($itemsData as $data) {
$item = $doc->items()->find($data['id']);
if ($item) {
$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,
]);
}
}
});
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\GoodsReceipt;
use App\Modules\Inventory\Models\GoodsReceiptItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Support\Facades\DB;
class GoodsReceiptService
{
protected $inventoryService;
protected $procurementService;
public function __construct(
InventoryServiceInterface $inventoryService,
ProcurementServiceInterface $procurementService
) {
$this->inventoryService = $inventoryService;
$this->procurementService = $procurementService;
}
/**
* Store a new Goods Receipt and process inventory.
*
* @param array $data
* @return GoodsReceipt
* @throws \Exception
*/
public function store(array $data)
{
return DB::transaction(function () use ($data) {
// 1. Generate Code
$data['code'] = $this->generateCode($data['received_date']);
$data['user_id'] = auth()->id();
$data['status'] = 'completed'; // Direct completion for now
// 2. Create Header
$goodsReceipt = GoodsReceipt::create($data);
// 3. Process Items
foreach ($data['items'] as $itemData) {
// Create GR Item
$grItem = new GoodsReceiptItem([
'product_id' => $itemData['product_id'],
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
'quantity_received' => $itemData['quantity_received'],
'unit_price' => $itemData['unit_price'],
'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'],
'batch_number' => $itemData['batch_number'] ?? null,
'expiry_date' => $itemData['expiry_date'] ?? null,
]);
$goodsReceipt->items()->save($grItem);
// 4. Update Inventory
$reason = match($goodsReceipt->type) {
'standard' => '採購進貨',
'miscellaneous' => '雜項入庫',
'other' => '其他入庫',
default => '進貨入庫',
};
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $goodsReceipt->warehouse_id,
'product_id' => $grItem->product_id,
'quantity' => $grItem->quantity_received,
'unit_cost' => $grItem->unit_price,
'batch_number' => $grItem->batch_number,
'expiry_date' => $grItem->expiry_date,
'reason' => $reason,
'reference_type' => GoodsReceipt::class,
'reference_id' => $goodsReceipt->id,
'source_purchase_order_id' => $goodsReceipt->purchase_order_id,
'arrival_date' => $goodsReceipt->received_date,
]);
// 5. Update PO if linked and type is standard
if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id && $grItem->purchase_order_item_id) {
$this->procurementService->updateReceivedQuantity(
$grItem->purchase_order_item_id,
$grItem->quantity_received
);
}
}
return $goodsReceipt;
});
}
private function generateCode(string $date)
{
// Format: GR + YYYYMMDD + NNN
$prefix = 'GR' . date('Ymd', strtotime($date));
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
->orderBy('id', 'desc')
->lockForUpdate()
->first();
if ($last) {
$seq = intval(substr($last->code, -3)) + 1;
} else {
$seq = 1;
}
return $prefix . str_pad($seq, 3, '0', STR_PAD_LEFT);
}
}

View File

@@ -17,7 +17,7 @@ class InventoryService implements InventoryServiceInterface
public function getAllProducts()
{
return Product::with(['baseUnit'])->get();
return Product::with(['baseUnit', 'largeUnit'])->get();
}
public function getUnits()
@@ -32,17 +32,17 @@ class InventoryService implements InventoryServiceInterface
public function getProduct(int $id)
{
return Product::find($id);
return Product::with(['baseUnit', 'largeUnit'])->find($id);
}
public function getProductsByIds(array $ids)
{
return Product::whereIn('id', $ids)->get();
return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get();
}
public function getProductsByName(string $name)
{
return Product::where('name', 'like', "%{$name}%")->get();
return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get();
}
public function getWarehouse(int $id)

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryTransferOrder;
use App\Modules\Inventory\Models\InventoryTransferItem;
use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class TransferService
{
/**
* 建立調撥單草稿
*/
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId): InventoryTransferOrder
{
return InventoryTransferOrder::create([
'from_warehouse_id' => $fromWarehouseId,
'to_warehouse_id' => $toWarehouseId,
'status' => 'draft',
'remarks' => $remarks,
'created_by' => $userId,
]);
}
/**
* 更新調撥單明細
*/
public function updateItems(InventoryTransferOrder $order, array $itemsData): void
{
DB::transaction(function () use ($order, $itemsData) {
$order->items()->delete();
foreach ($itemsData as $data) {
$order->items()->create([
'product_id' => $data['product_id'],
'batch_number' => $data['batch_number'] ?? null,
'quantity' => $data['quantity'],
'notes' => $data['notes'] ?? null,
]);
}
});
}
/**
* 過帳 (Post) - 執行調撥 (直接扣除來源,增加目的)
*/
public function post(InventoryTransferOrder $order, int $userId): void
{
DB::transaction(function () use ($order, $userId) {
$fromWarehouse = $order->fromWarehouse;
$toWarehouse = $order->toWarehouse;
foreach ($order->items as $item) {
if ($item->quantity <= 0) continue;
// 1. 處理來源倉 (扣除)
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
throw ValidationException::withMessages([
'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足"],
]);
}
$oldSourceQty = $sourceInventory->quantity;
$newSourceQty = $oldSourceQty - $item->quantity;
$sourceInventory->quantity = $newSourceQty;
// 更新總值 (假設成本不變)
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
$sourceInventory->save();
// 記錄來源交易
$sourceInventory->transactions()->create([
'type' => '調撥出庫',
'quantity' => -$item->quantity,
'unit_cost' => $sourceInventory->unit_cost,
'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty,
'reason' => "調撥單 {$order->doc_no}{$toWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
]);
// 2. 處理目的倉 (增加)
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $order->to_warehouse_id,
'product_id' => $item->product_id,
'batch_number' => $item->batch_number,
],
[
'quantity' => 0,
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
'total_value' => 0,
// 繼承其他屬性
'expiry_date' => $sourceInventory->expiry_date,
'quality_status' => $sourceInventory->quality_status,
'origin_country' => $sourceInventory->origin_country,
]
);
// 若是新建立的且成本為0確保繼承成本
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
$targetInventory->unit_cost = $sourceInventory->unit_cost;
}
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $item->quantity;
$targetInventory->quantity = $newTargetQty;
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
$targetInventory->save();
// 記錄目的交易
$targetInventory->transactions()->create([
'type' => '調撥入庫',
'quantity' => $item->quantity,
'unit_cost' => $targetInventory->unit_cost,
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
]);
}
$order->update([
'status' => 'completed',
'posted_at' => now(),
'posted_by' => $userId,
]);
});
}
public function void(InventoryTransferOrder $order, int $userId): void
{
if ($order->status !== 'draft') {
throw new \Exception('只能作廢草稿狀態的單據');
}
$order->update([
'status' => 'voided',
'updated_by' => $userId
]);
}
}

View File

@@ -31,4 +31,52 @@ interface ProcurementServiceInterface
* @return array
*/
public function getDashboardStats(): array;
/**
* Update received quantity for a PO item.
*
* @param int $poItemId
* @param float $quantity
* @return void
*/
public function updateReceivedQuantity(int $poItemId, float $quantity): void;
/**
* Search pending or partial purchase orders.
*
* @param string $query
* @return Collection
*/
public function searchPendingPurchaseOrders(string $query): Collection;
/**
* Search vendors by name or code.
*
* @param string $query
* @return Collection
*/
public function searchVendors(string $query): Collection;
/**
* 取得所有待進貨的採購單列表(不需搜尋條件)。
* 用於進貨單頁面直接顯示可選擇的採購單。
*
* @return Collection
*/
public function getPendingPurchaseOrders(): Collection;
/**
* 取得所有廠商列表。
*
* @return Collection
*/
public function getAllVendors(): Collection;
/**
* Get vendors by multiple IDs.
*
* @param array $ids
* @return Collection
*/
public function getVendorsByIds(array $ids): Collection;
}

View File

@@ -187,9 +187,10 @@ class PurchaseOrderController extends Controller
try {
DB::beginTransaction();
// 生成單號YYYYMMDD001
// 生成單號:POYYYYMMDD001
$today = now()->format('Ymd');
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
$prefix = 'PO' . $today;
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
->lockForUpdate() // 鎖定以避免並發衝突
->orderBy('code', 'desc')
->first();
@@ -201,7 +202,7 @@ class PurchaseOrderController extends Controller
} else {
$sequence = '001';
}
$code = $today . $sequence;
$code = $prefix . $sequence;
$totalAmount = 0;
foreach ($validated['items'] as $item) {
@@ -419,7 +420,7 @@ class PurchaseOrderController extends Controller
'order_date' => 'required|date', // 新增驗證
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
'status' => 'required|string|in:draft,pending,approved,partial,completed,closed,cancelled',
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
'invoice_date' => 'nullable|date',
'invoice_amount' => 'nullable|numeric|min:0',
@@ -476,14 +477,21 @@ class PurchaseOrderController extends Controller
$order->saveQuietly();
// 2. 捕捉包含商品名稱的舊項目以進行比對
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
$oldItemsCollection = $order->items()->get();
$oldProductIds = $oldItemsCollection->pluck('product_id')->unique()->toArray();
$oldProducts = $this->inventoryService->getProductsByIds($oldProductIds)->keyBy('id');
// 注意:單位的獲取可能也需要透過 InventoryService但目前假設單位的關聯是合法的如果在同一模組
// 如果單位也在不同模組,則需要另外處理。這裡暫時假設可以動手水和一下基本單位名稱。
$oldItems = $oldItemsCollection->map(function($item) use ($oldProducts) {
$product = $oldProducts->get($item->product_id);
return [
'id' => $item->id,
'product_id' => $item->product_id,
'product_name' => $item->product?->name,
'product_name' => $product?->name ?? 'Unknown',
'quantity' => (float) $item->quantity,
'unit_id' => $item->unit_id,
'unit_name' => $item->unit?->name,
'unit_name' => 'N/A', // 簡化處理,或可透過服務獲取
'subtotal' => (float) $item->subtotal,
];
})->keyBy('product_id');
@@ -513,14 +521,19 @@ class PurchaseOrderController extends Controller
'updated' => [],
];
// 重新獲取新項目以確保擁有最新的關聯
$newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) {
// 重新獲取新項目並水和產品資料
$newItemsCollection = $order->items()->get();
$newProductIds = $newItemsCollection->pluck('product_id')->unique()->toArray();
$newProducts = $this->inventoryService->getProductsByIds($newProductIds)->keyBy('id');
$newItemsFormatted = $newItemsCollection->map(function($item) use ($newProducts) {
$product = $newProducts->get($item->product_id);
return [
'product_id' => $item->product_id,
'product_name' => $item->product?->name,
'product_name' => $product?->name ?? 'Unknown',
'quantity' => (float) $item->quantity,
'unit_id' => $item->unit_id,
'unit_name' => $item->unit?->name,
'unit_name' => 'N/A',
'subtotal' => (float) $item->subtotal,
];
})->keyBy('product_id');

View File

@@ -95,14 +95,15 @@ class VendorController extends Controller
if (!$product) return null;
return (object) [
'id' => (string) $pivot->id,
'productId' => (string) $product->id,
'productName' => $product->name,
'unit' => $product->baseUnit?->name ?? 'N/A',
'baseUnit' => $product->baseUnit?->name,
'largeUnit' => $product->largeUnit?->name,
'conversionRate' => (float) $product->conversion_rate,
'lastPrice' => (float) $pivot->last_price,
'id' => (string) $product->id, // Frontend expects product ID here as p.id
'name' => $product->name,
'baseUnit' => $product->baseUnit ? (object)['name' => $product->baseUnit->name] : null,
'largeUnit' => $product->largeUnit ? (object)['name' => $product->largeUnit->name] : null,
'conversion_rate' => (float) $product->conversion_rate,
'purchase_unit' => $product->purchaseUnit?->name,
'pivot' => (object) [
'last_price' => (float) $pivot->last_price,
],
];
})->filter()->values();
@@ -119,7 +120,7 @@ class VendorController extends Controller
'email' => $vendor->email,
'address' => $vendor->address,
'remark' => $vendor->remark,
'supplyProducts' => $supplyProducts,
'products' => $supplyProducts, // Changed from supplyProducts to products
];
return Inertia::render('Vendor/Show', [

View File

@@ -29,4 +29,74 @@ class ProcurementService implements ProcurementServiceInterface
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
];
}
public function updateReceivedQuantity(int $poItemId, float $quantity): void
{
$item = \App\Modules\Procurement\Models\PurchaseOrderItem::findOrFail($poItemId);
$item->increment('received_quantity', $quantity);
$item->refresh();
// Check PO status
$po = $item->purchaseOrder;
// Load items to check completion
$po->load('items');
$allReceived = $po->items->every(function ($i) {
return $i->received_quantity >= $i->quantity;
});
$anyReceived = $po->items->contains(function ($i) {
return $i->received_quantity > 0;
});
if ($allReceived) {
$po->status = 'completed'; // or 'received' based on workflow
} elseif ($anyReceived) {
$po->status = 'partial';
}
$po->save();
}
public function searchPendingPurchaseOrders(string $query): Collection
{
return PurchaseOrder::with(['vendor', 'items'])
->whereIn('status', ['approved', 'partial'])
->where(function($q) use ($query) {
$q->where('code', 'like', "%{$query}%")
->orWhereHas('vendor', function($vq) use ($query) {
$vq->where('name', 'like', "%{$query}%");
});
})
->limit(20)
->get();
}
public function searchVendors(string $query): Collection
{
return \App\Modules\Procurement\Models\Vendor::where('name', 'like', "%{$query}%")
->orWhere('code', 'like', "%{$query}%")
->limit(20)
->get(['id', 'name', 'code']);
}
public function getPendingPurchaseOrders(): Collection
{
return PurchaseOrder::with(['vendor', 'items'])
->whereIn('status', ['approved', 'partial'])
->orderBy('created_at', 'desc')
->limit(50)
->get();
}
public function getAllVendors(): Collection
{
return \App\Modules\Procurement\Models\Vendor::orderBy('name')->get(['id', 'name', 'code']);
}
public function getVendorsByIds(array $ids): Collection
{
return \App\Modules\Procurement\Models\Vendor::whereIn('id', $ids)->get(['id', 'name', 'code']);
}
}

View File

@@ -8,20 +8,28 @@ use App\Modules\Production\Models\ProductionOrder;
use App\Modules\Production\Models\ProductionOrderItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Core\Contracts\CoreServiceInterface;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class ProductionOrderController extends Controller
{
protected $inventoryService;
protected $coreService;
protected $procurementService;
public function __construct(InventoryServiceInterface $inventoryService, CoreServiceInterface $coreService)
public function __construct(
InventoryServiceInterface $inventoryService,
CoreServiceInterface $coreService,
ProcurementServiceInterface $procurementService
)
{
$this->inventoryService = $inventoryService;
$this->coreService = $coreService;
$this->procurementService = $procurementService;
}
/**
@@ -37,9 +45,6 @@ class ProductionOrderController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('code', 'like', "%{$search}%")
->orWhere('output_batch_number', 'like', "%{$search}%");
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
$q->where('code', 'like', "%{$search}%")
->orWhere('output_batch_number', 'like', "%{$search}%");
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
@@ -205,15 +210,29 @@ class ProductionOrderController extends Controller
// 手動水和明細資料
$items = $productionOrder->items;
$inventoryIds = $items->pluck('inventory_id')->unique()->filter()->toArray();
// 修正: 移除跨模組關聯 sourcePurchaseOrder.vendor
$inventories = $this->inventoryService->getInventoriesByIds(
$inventoryIds,
['product.baseUnit', 'sourcePurchaseOrder.vendor']
['product.baseUnit']
)->keyBy('id');
// 手動載入 Purchase Orders
$poIds = $inventories->pluck('source_purchase_order_id')->unique()->filter()->toArray();
$purchaseOrders = collect();
if (!empty($poIds)) {
$purchaseOrders = $this->procurementService->getPurchaseOrdersByIds($poIds, ['vendor'])->keyBy('id');
}
$units = $this->inventoryService->getUnits()->keyBy('id');
foreach ($items as $item) {
$item->inventory = $inventories->get($item->inventory_id);
if ($item->inventory) {
// 手動掛載 PO
$poId = $item->inventory->source_purchase_order_id;
$item->inventory->sourcePurchaseOrder = $purchaseOrders->get($poId);
}
$item->unit = $units->get($item->unit_id);
}

View File

@@ -188,4 +188,118 @@ class RecipeController extends Controller
$recipe->delete();
return redirect()->back()->with('success', '配方已刪除');
}
/**
* 獲取配方詳細資料 (API)
*/
/**
* 獲取配方詳細資料 (API)
*/
public function show(Recipe $recipe)
{
// Manual Hydration for strict modularity
$recipe->product = $this->inventoryService->getProduct($recipe->product_id);
$items = $recipe->items;
$productIds = $items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$units = $this->inventoryService->getUnits()->keyBy('id');
foreach ($items as $item) {
$item->product = $products->get($item->product_id);
$item->unit = $units->get($item->unit_id);
}
return response()->json($recipe);
}
/**
* 獲取商品最新有效配方 (API)
*/
public function getLatestByProduct($productId)
{
// 放寬條件,只要 product_id 相符就抓最新的
$recipe = Recipe::where('product_id', (int)$productId)
->orderBy('created_at', 'desc')
->first();
if (!$recipe) {
return response()->json(null);
}
// Load items with product info
$items = $recipe->items;
$productIds = $items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$formattedItems = $items->map(function ($item) use ($products) {
$product = $products->get($item->product_id);
return [
'product_id' => $item->product_id,
'product_name' => $product->name ?? '未知商品',
'product_code' => $product->code ?? '',
'quantity' => $item->quantity,
'unit_id' => $item->unit_id,
'unit_name' => $product->baseUnit->name ?? '',
];
});
return response()->json([
'id' => $recipe->id,
'name' => $recipe->name,
'code' => $recipe->code,
'yield_quantity' => $recipe->yield_quantity,
'items' => $formattedItems,
]);
}
/**
* 獲取商品所有有效配方列表 (API)
*/
public function getByProduct($productId)
{
$recipes = Recipe::where('product_id', (int)$productId)
->where('is_active', true)
->orderBy('created_at', 'desc')
->get();
if ($recipes->isEmpty()) {
return response()->json([]);
}
// 預先載入必要的關聯與數據
// 為了效能,我們只在列表顯示基本資訊,詳細 Item 資料等選中後再透過 getLatestByProduct (或是重構為 getDetails) 獲取
// 不過為了前端方便,若配方不多,直接回傳完整結構也可以。
// 這裡選擇回傳完整結構,因為配方通常不會太多
$recipes->load('items');
// 收集所有 recipe items 中的 product ids
$allProductIds = $recipes->pluck('items')->flatten()->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($allProductIds)->keyBy('id');
$result = $recipes->map(function ($recipe) use ($products) {
$formattedItems = $recipe->items->map(function ($item) use ($products) {
$product = $products->get($item->product_id);
return [
'product_id' => $item->product_id,
'product_name' => $product->name ?? '未知商品',
'product_code' => $product->code ?? '',
'quantity' => $item->quantity,
'unit_id' => $item->unit_id,
'unit_name' => $product->baseUnit->name ?? '',
];
});
return [
'id' => $recipe->id,
'name' => $recipe->name,
'code' => $recipe->code,
'yield_quantity' => $recipe->yield_quantity,
'items' => $formattedItems,
'created_at' => $recipe->created_at->toIso8601String(),
];
});
return response()->json($result);
}
}

View File

@@ -27,5 +27,13 @@ class RecipeItem extends Model
return $this->belongsTo(Recipe::class);
}
public function product()
{
return $this->belongsTo(\App\Modules\Inventory\Models\Product::class);
}
public function unit()
{
return $this->belongsTo(\App\Modules\Inventory\Models\Unit::class);
}
}

View File

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

View File

@@ -8,8 +8,6 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
use Spatie\Permission\Exceptions\UnauthorizedException;
use Inertia\Inertia;
// 信任所有代理(用於反向代理環境)
TrustProxies::at('*');
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -18,6 +16,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// 信任所有代理(用於反向代理環境)
$middleware->trustProxies(at: '*');
// Tenancy 必須最先執行,確保資料庫連線在 Session 讀取之前建立
$middleware->web(prepend: [
\App\Http\Middleware\UniversalTenancy::class,

View File

@@ -129,7 +129,7 @@ return [
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
Str::slug((string) env('APP_NAME', 'laravel')).'_v2_session'
),
/*

View File

@@ -204,6 +204,6 @@ return [
*/
'seeder_parameters' => [
'--class' => 'TenantDatabaseSeeder', // 租戶專用 seeder
// '--force' => true, // This needs to be true to seed tenant databases in production
'--force' => true, // 強制在正式環境執行 Seeder
],
];

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateActivityLogTable extends Migration
{
public function up()
{
Schema::connection(config('activitylog.database_connection'))->create(config('activitylog.table_name'), function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('log_name')->nullable();
$table->text('description');
$table->nullableMorphs('subject', 'subject');
$table->nullableMorphs('causer', 'causer');
$table->json('properties')->nullable();
$table->timestamps();
$table->index('log_name');
});
}
public function down()
{
Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name'));
}
}

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddEventColumnToActivityLogTable extends Migration
{
public function up()
{
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
$table->string('event')->nullable()->after('subject_type');
});
}
public function down()
{
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
$table->dropColumn('event');
});
}
}

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddBatchUuidColumnToActivityLogTable extends Migration
{
public function up()
{
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
$table->uuid('batch_uuid')->nullable()->after('properties');
});
}
public function down()
{
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
$table->dropColumn('batch_uuid');
});
}
}

View File

@@ -0,0 +1,45 @@
<?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::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
// 單欄索引:事件類型(高頻過濾條件)
$table->index('event', 'idx_event');
// 單欄索引:批次 UUID未來批次操作查詢
$table->index('batch_uuid', 'idx_batch_uuid');
// 複合索引 1時間 + 事件類型(最常見的組合查詢)
$table->index(['created_at', 'event'], 'idx_created_event');
// 複合索引 2主體類型 + 主體 ID + 時間(查詢特定資源的操作歷史)
$table->index(['subject_type', 'subject_id', 'created_at'], 'idx_subject_created');
// 複合索引 3操作者 + 時間(查詢特定使用者的操作紀錄)
$table->index(['causer_id', 'created_at'], 'idx_causer_created');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
$table->dropIndex('idx_event');
$table->dropIndex('idx_batch_uuid');
$table->dropIndex('idx_created_event');
$table->dropIndex('idx_subject_created');
$table->dropIndex('idx_causer_created');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('warehouses', function (Blueprint $table) {
$table->dropColumn('is_sellable');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('warehouses', function (Blueprint $table) {
$table->boolean('is_sellable')->default(true)->after('description')->comment('是否可銷售');
});
}
};

View File

@@ -0,0 +1,50 @@
<?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('goods_receipts', function (Blueprint $table) {
$table->id();
$table->string('code')->index(); // GR 單號
$table->foreignId('warehouse_id')->constrained()->onDelete('restrict');
$table->foreignId('purchase_order_id')->nullable()->constrained()->onDelete('set null');
$table->foreignId('vendor_id')->constrained()->onDelete('restrict'); // 關聯到 Inventory 模組內的 Vendor 邏輯或跨模組 ID (此處僅 FK 約束通常指向同一 DB 的 vendors 表)
$table->date('received_date');
$table->enum('status', ['draft', 'completed', 'cancelled'])->default('draft');
$table->text('remarks')->nullable();
$table->foreignId('user_id')->constrained()->onDelete('restrict'); // 經辦人
$table->timestamps();
$table->softDeletes();
});
Schema::create('goods_receipt_items', function (Blueprint $table) {
$table->id();
$table->foreignId('goods_receipt_id')->constrained()->onDelete('cascade');
$table->foreignId('product_id')->constrained()->onDelete('restrict');
$table->foreignId('purchase_order_item_id')->nullable()->constrained()->onDelete('set null'); // 用於回寫 PO Item
$table->decimal('quantity_received', 10, 2);
$table->decimal('unit_price', 10, 2); // 暫定價格 (來自 PO)
$table->decimal('total_amount', 12, 2); // 小計
$table->string('batch_number')->nullable();
$table->date('expiry_date')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('goods_receipt_items');
Schema::dropIfExists('goods_receipts');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('goods_receipts', function (Blueprint $table) {
$table->enum('type', ['standard', 'miscellaneous', 'other'])->default('standard')->after('warehouse_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('goods_receipts', function (Blueprint $table) {
$table->dropColumn('type');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Update old statuses to 'approved'
DB::table('purchase_orders')
->whereIn('status', ['processing', 'shipping', 'confirming'])
->update(['status' => 'approved']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Cannot easily reverse without knowing original status,
// but typically we can revert 'approved' back to 'processing' as a safeguard if needed,
// or just leave it since 'approved' is broader.
// For strict reversal, we might try to map back, but effectively this is a one-way consolidation.
// We will leave it as is for down/safe side.
}
};

View File

@@ -0,0 +1,54 @@
<?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('inventory_count_docs', function (Blueprint $table) {
$table->id();
$table->string('doc_no')->unique(); // 單號
$table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete();
$table->string('status')->default('draft'); // draft, counting, completed, cancelled
$table->timestamp('snapshot_date')->nullable(); // 快照建立時間
$table->timestamp('completed_at')->nullable(); // 完成時間
$table->string('remarks')->nullable();
// 審核/建立資訊
$table->foreignId('created_by')->constrained('users');
$table->foreignId('updated_by')->nullable()->constrained('users');
$table->foreignId('completed_by')->nullable()->constrained('users');
$table->timestamps();
});
Schema::create('inventory_count_items', function (Blueprint $table) {
$table->id();
$table->foreignId('count_doc_id')->constrained('inventory_count_docs')->cascadeOnDelete();
$table->foreignId('product_id')->constrained('products');
$table->string('batch_number')->nullable(); // 針對特定批號盤點
$table->decimal('system_qty', 10, 2)->default(0); // 系統帳面數量 (快照當下)
$table->decimal('counted_qty', 10, 2)->nullable(); // 實盤數量
$table->decimal('diff_qty', 10, 2)->default(0); // 差異 (實盤 - 系統)
$table->string('notes')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('inventory_count_items');
Schema::dropIfExists('inventory_count_docs');
}
};

View File

@@ -0,0 +1,53 @@
<?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('inventory_adjust_docs', function (Blueprint $table) {
$table->id();
$table->string('doc_no')->unique(); // 單號
$table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete();
$table->string('status')->default('draft'); // draft, posted, voided
$table->string('reason')->nullable(); // 調整原因 (e.g. 報廢, 盤盈虧, 其他)
$table->string('remarks')->nullable();
// 審核/建立資訊
$table->timestamp('posted_at')->nullable(); // 過帳時間
$table->foreignId('created_by')->constrained('users');
$table->foreignId('updated_by')->nullable()->constrained('users');
$table->foreignId('posted_by')->nullable()->constrained('users');
$table->timestamps();
});
Schema::create('inventory_adjust_items', function (Blueprint $table) {
$table->id();
$table->foreignId('adjust_doc_id')->constrained('inventory_adjust_docs')->cascadeOnDelete();
$table->foreignId('product_id')->constrained('products');
$table->string('batch_number')->nullable();
// 記錄當下 "調整前" 的庫存與成本 (參考用)
$table->decimal('qty_before', 10, 2)->default(0);
// 實際調整的數量 (可以正負, 正=增加, 負=減少)
$table->decimal('adjust_qty', 10, 2);
$table->string('notes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('inventory_adjust_items');
Schema::dropIfExists('inventory_adjust_docs');
}
};

View File

@@ -0,0 +1,47 @@
<?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('inventory_transfer_orders', function (Blueprint $table) {
$table->id();
$table->string('doc_no')->unique();
$table->foreignId('from_warehouse_id')->constrained('warehouses')->cascadeOnDelete();
$table->foreignId('to_warehouse_id')->constrained('warehouses')->cascadeOnDelete();
$table->string('status')->default('draft'); // draft, completed, voided
$table->string('remarks')->nullable();
// 審核/建立資訊
$table->timestamp('posted_at')->nullable(); // 過帳時間
$table->foreignId('created_by')->constrained('users');
$table->foreignId('updated_by')->nullable()->constrained('users');
$table->foreignId('posted_by')->nullable()->constrained('users');
$table->timestamps();
});
Schema::create('inventory_transfer_items', function (Blueprint $table) {
$table->id();
$table->foreignId('transfer_order_id')->constrained('inventory_transfer_orders')->cascadeOnDelete();
$table->foreignId('product_id')->constrained('products');
$table->string('batch_number')->nullable();
$table->decimal('quantity', 10, 2);
$table->string('notes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('inventory_transfer_items');
Schema::dropIfExists('inventory_transfer_orders');
}
};

View File

@@ -0,0 +1,32 @@
<?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_adjust_docs', function (Blueprint $table) {
$table->foreignId('count_doc_id')
->after('doc_no')
->nullable()
->constrained('inventory_count_docs')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventory_adjust_docs', function (Blueprint $table) {
$table->dropConstrainedForeignId('count_doc_id');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->string('barcode')->nullable()->unique()->index()->after('code')->comment('條碼編號');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('barcode');
});
}
};

View File

@@ -36,7 +36,27 @@ class PermissionSeeder extends Seeder
'inventory.view',
'inventory.view_cost', // 查看成本與價值
'inventory.adjust',
'inventory.transfer',
'inventory.count', // 庫存盤點
'inventory.transfer', // 庫存調撥
'inventory.delete',
// 進貨單管理
'goods_receipts.view',
'goods_receipts.create',
'goods_receipts.edit',
'goods_receipts.delete',
// 生產工單管理
'production_orders.view',
'production_orders.create',
'production_orders.edit',
'production_orders.delete',
// 配方管理
'recipes.view',
'recipes.create',
'recipes.edit',
'recipes.delete',
// 供應商管理
'vendors.view',
@@ -97,7 +117,10 @@ class PermissionSeeder extends Seeder
'products.view', 'products.create', 'products.edit', 'products.delete',
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
'purchase_orders.delete', 'purchase_orders.publish',
'inventory.view', 'inventory.view_cost', 'inventory.adjust', 'inventory.transfer',
'inventory.view', 'inventory.view_cost', 'inventory.adjust', 'inventory.transfer', 'inventory.delete',
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.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',
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
'users.view', 'users.create', 'users.edit',
@@ -110,7 +133,9 @@ class PermissionSeeder extends Seeder
// warehouse-manager 管理庫存與倉庫
$warehouseManager->givePermissionTo([
'products.view',
'inventory.view', 'inventory.adjust', 'inventory.transfer',
'inventory.view', 'inventory.adjust', 'inventory.count', 'inventory.transfer', 'inventory.delete',
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
'production_orders.view', 'production_orders.create', 'production_orders.edit',
'warehouses.view', 'warehouses.create', 'warehouses.edit',
]);
@@ -120,6 +145,7 @@ class PermissionSeeder extends Seeder
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
'vendors.view', 'vendors.create', 'vendors.edit',
'inventory.view',
'goods_receipts.view', 'goods_receipts.create',
]);
// viewer 僅能查看
@@ -127,6 +153,7 @@ class PermissionSeeder extends Seeder
'products.view',
'purchase_orders.view',
'inventory.view',
'goods_receipts.view',
'vendors.view',
'warehouses.view',
'utility_fees.view',

View File

@@ -34,6 +34,9 @@ class TenantDatabaseSeeder extends Seeder
// 呼叫權限 Seeder 設定權限與角色
$this->call(PermissionSeeder::class);
// 初始化基本單位資料
$this->call(UnitSeeder::class);
// 確保 admin 擁有 super-admin 角色
if (!$admin->hasRole('super-admin')) {
$admin->assignRole('super-admin');

View File

@@ -15,6 +15,11 @@ fi
chmod -R ugo+rw /.composer
# 確保 storage 軟連結存在
if [ ! -L /var/www/html/public/storage ]; then
php /var/www/html/artisan storage:link
fi
if [ $# -gt 0 ]; then
if [ "$SUPERVISOR_PHP_USER" = "root" ]; then
exec "$@"

View File

@@ -15,6 +15,11 @@ fi
chmod -R ugo+rw /.composer
# 確保 storage 軟連結存在
if [ ! -L /var/www/html/public/storage ]; then
php /var/www/html/artisan storage:link
fi
if [ $# -gt 0 ]; then
if [ "$SUPERVISOR_PHP_USER" = "root" ]; then
exec "$@"

59
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/lodash": "^4.17.21",
@@ -75,7 +76,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1672,6 +1672,52 @@
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
"integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
@@ -2539,7 +2585,6 @@
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2550,7 +2595,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2561,7 +2605,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2669,7 +2712,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2882,8 +2924,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
@@ -3762,7 +3803,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3824,7 +3864,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -3837,7 +3876,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -4330,7 +4368,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",

View File

@@ -31,6 +31,7 @@
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/lodash": "^4.17.21",

View File

@@ -114,10 +114,24 @@ const fieldLabels: Record<string, string> = {
transaction_date: '費用日期',
category: '費用類別',
amount: '金額',
// 進貨單欄位
gr_number: '進貨單號',
received_date: '入庫日期',
type: '入庫類型',
remarks: '備註',
// 生產管理欄位
production_number: '工單編號',
production_date: '生產日期',
actual_quantity: '實際產量',
consumption_status: '物料消耗狀態',
recipe_id: '生產配方',
recipe_name: '配方名稱',
yield_quantity: '預期產量',
};
// 採購單狀態對照表
// 狀態翻譯對照表
const statusMap: Record<string, string> = {
// 採購單狀態
draft: '草稿',
pending: '待審核',
approved: '已核准',
@@ -125,6 +139,10 @@ const statusMap: Record<string, string> = {
received: '已收貨',
cancelled: '已取消',
completed: '已完成',
// 生產工單狀態
planned: '已計畫',
in_progress: '生產中',
// completed 已定義
};
// 庫存品質狀態對照表

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import { Eye, Trash2 } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Link, useForm } from "@inertiajs/react";
import { toast } from "sonner";
import { Can } from "@/Components/Permission/Can";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
export interface GoodsReceipt {
id: number;
code: string;
warehouse_id: number;
warehouse?: { name: string };
vendor_id?: number;
vendor?: { name: string };
received_date: string;
status: string;
type?: string;
items_sum_total_amount?: number;
user?: { name: string };
}
export default function GoodsReceiptActions({
receipt,
}: { receipt: GoodsReceipt }) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { delete: destroy, processing } = useForm({});
const handleConfirmDelete = () => {
// @ts-ignore
destroy(route('goods-receipts.destroy', receipt.id), {
onSuccess: () => {
toast.success("進貨單已成功刪除");
setShowDeleteDialog(false);
},
onError: (errors: any) => toast.error(errors.error || "刪除過程中發生錯誤"),
});
};
return (
<div className="flex justify-center gap-2">
<Link href={route('goods-receipts.show', receipt.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查看詳情"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
{/* Delete typically restricted for Goods Receipts, checking permission */}
<Can permission="goods_receipts.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => setShowDeleteDialog(true)}
disabled={processing}
>
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{receipt.code}
<br />
<span className="text-red-500 font-bold mt-2 block">
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="button-outlined-primary"></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="button-filled-error"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { Badge } from "@/Components/ui/badge";
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" }> = {
processing: { label: "處理中", variant: "warning" },
completed: { label: "已完成", variant: "success" },
cancelled: { label: "已取消", variant: "destructive" },
};
interface GoodsReceiptStatusBadgeProps {
status: string;
className?: string;
}
export default function GoodsReceiptStatusBadge({
status,
className,
}: GoodsReceiptStatusBadgeProps) {
const config = GOODS_RECEIPT_STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
// Apply custom styling based on variant mapping if not using standard badge variants
let badgeClass = "";
switch (config.variant) {
case "success":
badgeClass = "bg-green-100 text-green-800 hover:bg-green-200 border-green-200";
break;
case "warning":
badgeClass = "bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border-yellow-200";
break;
case "destructive":
badgeClass = "bg-red-100 text-red-800 hover:bg-red-200 border-red-200";
break;
default:
badgeClass = "bg-gray-100 text-gray-800 hover:bg-gray-200 border-gray-200";
}
return (
<Badge
variant="outline"
className={`${className} font-medium px-2.5 py-0.5 rounded-full border ${badgeClass}`}
>
{config.label}
</Badge>
);
}

View File

@@ -0,0 +1,258 @@
/**
* 進貨單列表表格
*/
import { useState, useMemo } from "react";
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import GoodsReceiptActions, { GoodsReceipt } from "./GoodsReceiptActions";
import GoodsReceiptStatusBadge from "./GoodsReceiptStatusBadge";
import CopyButton from "@/Components/shared/CopyButton";
import { formatCurrency, formatDate } from "@/utils/format";
interface GoodsReceiptTableProps {
receipts: GoodsReceipt[];
}
type SortField = "code" | "type" | "warehouse_name" | "vendor_name" | "received_date" | "total_amount" | "status";
type SortDirection = "asc" | "desc" | null;
export default function GoodsReceiptTable({
receipts,
}: GoodsReceiptTableProps) {
const [sortField, setSortField] = useState<SortField | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
// 處理排序
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortDirection === "asc") {
setSortDirection("desc");
} else if (sortDirection === "desc") {
setSortDirection(null);
setSortField(null);
} else {
setSortDirection("asc");
}
} else {
setSortField(field);
setSortDirection("asc");
}
};
// 類型翻譯映射
const typeMap: Record<string, string> = {
standard: "標準採購",
miscellaneous: "雜項入庫",
other: "其他入庫",
};
// 排序後的進貨單列表
const sortedReceipts = useMemo(() => {
if (!sortField || !sortDirection) {
return receipts;
}
return [...receipts].sort((a, b) => {
let aValue: string | number;
let bValue: string | number;
switch (sortField) {
case "code":
aValue = a.code;
bValue = b.code;
break;
case "type":
aValue = typeMap[a.status] || a.status; // status here might actually refer to type in existing code logic? Let's use a.type if it exists.
// Checking if 'type' is in receipt - based on implementation plan we want it.
// Currently GoodsReceipt model HAS type.
// @ts-ignore
aValue = typeMap[a.type] || a.type || "";
// @ts-ignore
bValue = typeMap[b.type] || b.type || "";
break;
case "warehouse_name":
aValue = a.warehouse?.name || "";
bValue = b.warehouse?.name || "";
break;
case "vendor_name":
aValue = a.vendor?.name || "";
bValue = b.vendor?.name || "";
break;
case "received_date":
aValue = a.received_date;
bValue = b.received_date;
break;
case "total_amount":
aValue = a.items_sum_total_amount || 0;
bValue = b.items_sum_total_amount || 0;
break;
case "status":
aValue = a.status;
bValue = b.status;
break;
default:
return 0;
}
if (typeof aValue === "string" && typeof bValue === "string") {
return sortDirection === "asc"
? aValue.localeCompare(bValue, "zh-TW")
: bValue.localeCompare(aValue, "zh-TW");
} else {
return sortDirection === "asc"
? (aValue as number) - (bValue as number)
: (bValue as number) - (aValue as number);
}
});
}, [receipts, sortField, sortDirection]);
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="h-4 w-4 text-primary" />;
}
if (sortDirection === "desc") {
return <ArrowDown className="h-4 w-4 text-primary" />;
}
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
};
return (
<div className="bg-white rounded-lg border shadow-sm overflow-hidden mt-6">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50/50">
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead className="w-[180px]">
<button
onClick={() => handleSort("code")}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<SortIcon field="code" />
</button>
</TableHead>
<TableHead className="w-[120px]">
<button
onClick={() => handleSort("type")}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<SortIcon field="type" />
</button>
</TableHead>
<TableHead className="w-[180px]">
<button
onClick={() => handleSort("warehouse_name")}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<SortIcon field="warehouse_name" />
</button>
</TableHead>
<TableHead className="w-[180px]">
<button
onClick={() => handleSort("vendor_name")}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<SortIcon field="vendor_name" />
</button>
</TableHead>
<TableHead className="w-[150px]">
<button
onClick={() => handleSort("received_date")}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<SortIcon field="received_date" />
</button>
</TableHead>
<TableHead className="w-[140px] text-right">
<button
onClick={() => handleSort("total_amount")}
className="flex items-center gap-2 ml-auto hover:text-foreground transition-colors"
>
<SortIcon field="total_amount" />
</button>
</TableHead>
<TableHead className="w-[120px] text-center">
<button
onClick={() => handleSort("status")}
className="flex items-center gap-2 mx-auto hover:text-foreground transition-colors"
>
<SortIcon field="status" />
</button>
</TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedReceipts.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground py-12">
</TableCell>
</TableRow>
) : (
sortedReceipts.map((receipt, index) => (
<TableRow key={receipt.id}>
<TableCell className="text-gray-500 font-medium text-center">
{index + 1}
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<span className="font-mono text-sm font-medium">{receipt.code}</span>
<CopyButton text={receipt.code} label="複製單號" />
</div>
</TableCell>
<TableCell>
<span className="text-sm">
{/* @ts-ignore */}
{typeMap[receipt.type] || receipt.type || "-"}
</span>
</TableCell>
<TableCell>
<div className="text-sm font-medium text-gray-900">
{receipt.warehouse?.name || "-"}
</div>
</TableCell>
<TableCell>
<span className="text-sm text-gray-700">{receipt.vendor?.name || "-"}</span>
</TableCell>
<TableCell>
<span className="text-sm text-gray-500">{formatDate(receipt.received_date)}</span>
</TableCell>
<TableCell className="text-right">
<span className="font-semibold text-gray-900">
{formatCurrency(receipt.items_sum_total_amount)}
</span>
</TableCell>
<TableCell className="text-center">
<GoodsReceiptStatusBadge status={receipt.status} />
</TableCell>
<TableCell className="text-center">
<GoodsReceiptActions receipt={receipt} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useEffect } from "react";
import { Wand2 } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -36,6 +37,7 @@ export default function ProductDialog({
}: ProductDialogProps) {
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
code: "",
barcode: "",
name: "",
category_id: "",
brand: "",
@@ -52,6 +54,7 @@ export default function ProductDialog({
if (product) {
setData({
code: product.code,
barcode: product.barcode || "",
name: product.name,
category_id: product.categoryId.toString(),
brand: product.brand || "",
@@ -99,6 +102,11 @@ export default function ProductDialog({
}
};
const generateRandomBarcode = () => {
const randomDigits = Math.floor(Math.random() * 9000000000) + 1000000000;
setData("barcode", randomDigits.toString());
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
@@ -159,6 +167,32 @@ export default function ProductDialog({
{errors.code && <p className="text-sm text-red-500">{errors.code}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="barcode">
<span className="text-red-500">*</span>
</Label>
<div className="flex gap-2">
<Input
id="barcode"
value={data.barcode}
onChange={(e) => setData("barcode", e.target.value)}
placeholder="輸入條碼或自動生成"
className={`flex-1 ${errors.barcode ? "border-red-500" : ""}`}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={generateRandomBarcode}
title="隨機生成條碼"
className="shrink-0 button-outlined-primary"
>
<Wand2 className="h-4 w-4" />
</Button>
</div>
{errors.barcode && <p className="text-sm text-red-500">{errors.barcode}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="brand"></Label>
<Input

View File

@@ -74,11 +74,7 @@ export default function ProductTable({
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead>
<button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900">
<SortIcon field="code" />
</button>
</TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead>
<button onClick={() => onSort("name")} className="flex items-center hover:text-gray-900">
<SortIcon field="name" />
@@ -112,12 +108,15 @@ export default function ProductTable({
{startIndex + index}
</TableCell>
<TableCell className="font-mono text-sm text-gray-700">
{product.code}
{product.barcode || "-"}
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{product.name}</span>
{product.brand && <span className="text-xs text-gray-400">{product.brand}</span>}
<div className="flex items-center gap-2">
<span className="font-medium text-grey-0">{product.name}</span>
{product.brand && <Badge variant="secondary" className="text-[10px] h-4 px-1 bg-gray-100 text-gray-500 border-none">{product.brand}</Badge>}
</div>
<span className="text-xs text-gray-400 font-mono">: {product.code}</span>
</div>
</TableCell>
<TableCell>

View File

@@ -14,6 +14,17 @@ import {
TableHeader,
TableRow,
} from "@/Components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import type { PurchaseOrderItem, Supplier } from "@/types/purchase-order";
import { formatCurrency } from "@/utils/purchase-order";
@@ -204,14 +215,35 @@ export function PurchaseOrderItemsTable({
{/* 刪除按鈕 */}
{!isReadOnly && onRemoveItem && (
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
onClick={() => onRemoveItem(index)}
className="h-8 w-8 text-gray-300 hover:text-red-500 hover:bg-red-50 transition-colors"
>
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="移除項目"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveItem(index)}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
)}
</TableRow>

View File

@@ -4,6 +4,7 @@
import { Badge } from "@/Components/ui/badge";
import { PurchaseOrderStatus } from "@/types/purchase-order";
import { STATUS_CONFIG } from "@/constants/purchase-order";
interface PurchaseOrderStatusBadgeProps {
status: PurchaseOrderStatus;
@@ -14,33 +15,12 @@ export default function PurchaseOrderStatusBadge({
status,
className,
}: PurchaseOrderStatusBadgeProps) {
const getStatusConfig = (status: PurchaseOrderStatus) => {
switch (status) {
case "draft":
return { label: "草稿", className: "bg-gray-100 text-gray-700 border-gray-200" };
case "pending":
return { label: "待審核", className: "bg-blue-100 text-blue-700 border-blue-200" };
case "processing":
return { label: "處理中", className: "bg-yellow-100 text-yellow-700 border-yellow-200" };
case "shipping":
return { label: "運送中", className: "bg-purple-100 text-purple-700 border-purple-200" };
case "confirming":
return { label: "待確認", className: "bg-orange-100 text-orange-700 border-orange-200" };
case "completed":
return { label: "已完成", className: "bg-green-100 text-green-700 border-green-200" };
case "cancelled":
return { label: "已取消", className: "bg-red-100 text-red-700 border-red-200" };
default:
return { label: "未知", className: "bg-gray-100 text-gray-700 border-gray-200" };
}
};
const config = getStatusConfig(status);
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
return (
<Badge
variant="outline"
className={`${config.className} ${className} font-medium px-2.5 py-0.5 rounded-full`}
variant={config.variant}
className={`${className} font-medium px-2.5 py-0.5 rounded-full`}
>
{config.label}
</Badge>

View File

@@ -10,13 +10,13 @@ interface StatusProgressBarProps {
}
// 流程步驟定義
const FLOW_STEPS: { key: PurchaseOrderStatus | "approved"; label: string }[] = [
const FLOW_STEPS: { key: PurchaseOrderStatus; label: string }[] = [
{ key: "draft", label: "草稿" },
{ key: "pending", label: "待審核" },
{ key: "processing", label: "處理中" },
{ key: "shipping", label: "運送中" },
{ key: "confirming", label: "待確認" },
{ key: "completed", label: "已完成" },
{ key: "pending", label: "簽核中" },
{ key: "approved", label: "已核准" },
{ key: "partial", label: "部分收貨" },
{ key: "completed", label: "全數收貨" },
{ key: "closed", label: "已結案" },
];
export function StatusProgressBar({ currentStatus }: StatusProgressBarProps) {
@@ -82,7 +82,7 @@ export function StatusProgressBar({ currentStatus }: StatusProgressBarProps) {
: "text-gray-400"
}`}
>
{isRejectedAtThisStep ? "已取消" : step.label}
{isRejectedAtThisStep ? "已作廢" : step.label}
</p>
</div>
</div>

View File

@@ -100,12 +100,18 @@ export default function WarehouseCard({
{/* 統計區塊 - 狀態標籤 */}
<div className="space-y-3">
{/* 銷售狀態 */}
{/* 銷售狀態與可用性說明 */}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500"></span>
<Badge variant={warehouse.is_sellable ? "default" : "secondary"} className={warehouse.is_sellable ? "bg-green-600" : "bg-gray-400"}>
{warehouse.is_sellable ? "可銷售" : "暫停銷售"}
</Badge>
<span className="text-sm text-gray-500"></span>
{warehouse.type === 'quarantine' ? (
<Badge variant="secondary" className="bg-red-100 text-red-700 border-red-200">
</Badge>
) : (
<Badge variant="default" className="bg-green-600">
</Badge>
)}
</div>
{/* 低庫存警告狀態 */}

View File

@@ -62,7 +62,6 @@ export default function WarehouseDialog({
address: string;
description: string;
type: WarehouseType;
is_sellable: boolean;
license_plate: string;
driver_name: string;
}>({
@@ -71,7 +70,6 @@ export default function WarehouseDialog({
address: "",
description: "",
type: "standard",
is_sellable: true,
license_plate: "",
driver_name: "",
});
@@ -86,7 +84,6 @@ export default function WarehouseDialog({
address: warehouse.address || "",
description: warehouse.description || "",
type: warehouse.type || "standard",
is_sellable: warehouse.is_sellable ?? true,
license_plate: warehouse.license_plate || "",
driver_name: warehouse.driver_name || "",
});
@@ -97,7 +94,6 @@ export default function WarehouseDialog({
address: "",
description: "",
type: "standard",
is_sellable: true,
license_plate: "",
driver_name: "",
});
@@ -219,25 +215,7 @@ export default function WarehouseDialog({
</div>
)}
{/* 銷售設定 */}
<div className="space-y-4">
<div className="border-b pb-2">
<h4 className="text-sm text-gray-700"></h4>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="is_sellable"
className="h-4 w-4 rounded border-gray-300 text-primary-main focus:ring-primary-main"
checked={formData.is_sellable}
onChange={(e) => setFormData({ ...formData, is_sellable: e.target.checked })}
/>
<Label htmlFor="is_sellable"></Label>
</div>
<p className="text-xs text-gray-500 ml-6">
POS
</p>
</div>
{/* 區塊 B位置 */}
<div className="space-y-4">

View File

@@ -21,10 +21,12 @@ import {
Wallet,
BarChart3,
FileSpreadsheet,
BookOpen
BookOpen,
ClipboardCheck,
ArrowLeftRight
} from "lucide-react";
import { toast, Toaster } from "sonner";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { Link, usePage, Head } from "@inertiajs/react";
import { cn } from "@/lib/utils";
import BreadcrumbNav, { BreadcrumbItemType } from "@/Components/shared/BreadcrumbNav";
@@ -82,7 +84,7 @@ export default function AuthenticatedLayout({
id: "inventory-management",
label: "商品與庫存管理",
icon: <Boxes className="h-5 w-5" />,
permission: ["products.view", "warehouses.view"], // 滿足任一即可看到此群組
permission: ["products.view", "warehouses.view", "inventory.view"], // 滿足任一即可看到此群組
children: [
{
id: "product-management",
@@ -98,13 +100,34 @@ export default function AuthenticatedLayout({
route: "/warehouses",
permission: "warehouses.view",
},
{
id: "stock-counting",
label: "庫存盤點",
icon: <ClipboardCheck className="h-4 w-4" />,
route: "/inventory/count-docs",
permission: "inventory.view",
},
{
id: "stock-adjustment",
label: "庫存盤調",
icon: <FileText className="h-4 w-4" />,
route: "/inventory/adjust-docs",
permission: "inventory.adjust",
},
{
id: "stock-transfer",
label: "庫存調撥",
icon: <ArrowLeftRight className="h-4 w-4" />,
route: "/inventory/transfer-orders",
permission: "inventory.transfer",
},
],
},
{
id: "vendor-management",
label: "廠商管理",
id: "supply-chain-management",
label: "供應鏈管理",
icon: <Truck className="h-5 w-5" />,
permission: "vendors.view",
permission: ["vendors.view", "purchase_orders.view", "goods_receipts.view"],
children: [
{
id: "vendor-list",
@@ -113,14 +136,6 @@ export default function AuthenticatedLayout({
route: "/vendors",
permission: "vendors.view",
},
],
},
{
id: "purchase-management",
label: "採購管理",
icon: <ShoppingCart className="h-5 w-5" />,
permission: "purchase_orders.view",
children: [
{
id: "purchase-order-list",
label: "採購單管理",
@@ -128,6 +143,20 @@ export default function AuthenticatedLayout({
route: "/purchase-orders",
permission: "purchase_orders.view",
},
{
id: "goods-receipt-list",
label: "進貨單管理",
icon: <ClipboardCheck className="h-4 w-4" />,
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",
// },
],
},
{
@@ -253,7 +282,11 @@ export default function AuthenticatedLayout({
const activeItem = menuItems.find(item =>
item.children?.some(child => child.route && url.startsWith(child.route))
);
return activeItem ? [activeItem.id] : ["inventory-management"];
const defaultExpanded = ["inventory-management"];
if (activeItem && !defaultExpanded.includes(activeItem.id)) {
defaultExpanded.push(activeItem.id);
}
return defaultExpanded;
});
// 監聽 URL 變化,確保「當前」頁面所屬群組是展開的
@@ -277,17 +310,21 @@ export default function AuthenticatedLayout({
}, [isCollapsed]);
// 全域監聽 flash 訊息並顯示 Toast
const lastFlash = useRef<any>(null);
useEffect(() => {
// @ts-ignore
if (props.flash?.success) {
// @ts-ignore
if (!props.flash) return;
// 檢查是否與上次顯示的訊息相同透過簡單的物件引用比對Inertia 在重導向後會產生新的 props 物件)
if (props.flash === lastFlash.current) return;
if (props.flash.success) {
toast.success(props.flash.success);
}
// @ts-ignore
if (props.flash?.error) {
// @ts-ignore
if (props.flash.error) {
toast.error(props.flash.error);
}
lastFlash.current = props.flash;
}, [props.flash]);
const toggleExpand = (itemId: string) => {

View File

@@ -71,10 +71,13 @@ export default function RoleCreate({ groupedPermissions }: Props) {
'edit': '編輯',
'delete': '刪除',
'publish': '發布',
'adjust': '新增 / 調整',
'adjust': '調整',
'transfer': '調撥',
'safety_stock': '安全庫存設定',
'export': '匯出',
'complete': '完成',
'view_cost': '檢視成本',
'view_logs': '檢視日誌',
};
return map[action] || action;

View File

@@ -78,10 +78,13 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
'edit': '編輯',
'delete': '刪除',
'publish': '發布',
'adjust': '新增 / 調整',
'adjust': '調整',
'transfer': '調撥',
'safety_stock': '安全庫存設定',
'export': '匯出',
'complete': '完成',
'view_cost': '檢視成本',
'view_logs': '檢視日誌',
};
return map[action] || action;

View File

@@ -0,0 +1,470 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, router, Link } from '@inertiajs/react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { Label } from "@/Components/ui/label";
import { Plus, Search, X, Eye, Pencil, ClipboardCheck, Trash2 } from "lucide-react";
import { useState, useCallback, useEffect } from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import Pagination from '@/Components/shared/Pagination';
import { SearchableSelect } from '@/Components/ui/searchable-select';
import { Can } from '@/Components/Permission/Can';
import debounce from 'lodash/debounce';
import axios from 'axios';
interface Doc {
id: string;
doc_no: string;
warehouse_name: string;
reason: string;
status: string;
created_by: string;
created_at: string;
posted_at: string;
}
interface Warehouse {
id: string;
name: string;
}
interface Filters {
search?: string;
warehouse_id?: string;
per_page?: string;
}
interface DocsPagination {
data: Doc[];
current_page: number;
per_page: number;
total: number;
links: any[]; // Adjust type as needed for pagination links
}
export default function Index({ docs, warehouses, filters }: { docs: DocsPagination, warehouses: Warehouse[], filters: Filters }) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(filters.search || '');
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || '');
const [perPage, setPerPage] = useState(filters.per_page || '15');
const [deleteId, setDeleteId] = useState<string | null>(null);
// For Count Doc Selection
const [pendingCounts, setPendingCounts] = useState<any[]>([]);
const [loadingPending, setLoadingPending] = useState(false);
const [scanSearch, setScanSearch] = useState('');
const fetchPendingCounts = useCallback(
debounce((search = '') => {
setLoadingPending(true);
axios.get(route('inventory.adjust.pending-counts'), { params: { search } })
.then(res => setPendingCounts(res.data))
.finally(() => setLoadingPending(false));
}, 300),
[]
);
useEffect(() => {
if (isDialogOpen) {
fetchPendingCounts();
}
}, [isDialogOpen, fetchPendingCounts]);
const debouncedFilter = useCallback(
debounce((params: Filters) => {
router.get(route('inventory.adjust.index'), params as any, {
preserveState: true,
replace: true,
});
}, 300),
[]
);
const handleSearchChange = (val: string) => {
setSearchQuery(val);
debouncedFilter({ search: val, warehouse_id: warehouseId, per_page: perPage });
};
const handleWarehouseChange = (val: string) => {
setWarehouseId(val);
debouncedFilter({ search: searchQuery, warehouse_id: val, per_page: perPage });
};
const handlePerPageChange = (val: string) => {
setPerPage(val);
debouncedFilter({ search: searchQuery, warehouse_id: warehouseId, per_page: val });
};
const confirmDelete = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
setDeleteId(id);
};
const handleDelete = () => {
if (deleteId) {
router.delete(route('inventory.adjust.destroy', [deleteId]), {
onSuccess: () => setDeleteId(null),
onError: () => setDeleteId(null),
});
}
};
const { data, setData, post, processing, reset } = useForm({
count_doc_id: null as string | null,
warehouse_id: '',
reason: '手動調整庫存',
remarks: '',
});
const handleCreate = (countDocId?: string) => {
if (countDocId) {
setData('count_doc_id', countDocId);
router.post(route('inventory.adjust.store'), { count_doc_id: countDocId }, {
onSuccess: () => setIsDialogOpen(false),
});
return;
}
post(route('inventory.adjust.store'), {
onSuccess: () => {
setIsDialogOpen(false);
reset();
},
});
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <Badge variant="secondary" className="bg-gray-100 text-gray-600 border-none">稿</Badge>;
case 'posted':
return <Badge className="bg-green-100 text-green-700 border-none"></Badge>;
case 'voided':
return <Badge variant="destructive" className="bg-red-100 text-red-700 border-none"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '商品與庫存管理', href: '#' },
{ label: '庫存盤調', href: route('inventory.adjust.index'), isPage: true },
]}
>
<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">
<ClipboardCheck className="h-6 w-6 text-primary-main" />
調
</h1>
<p className="text-gray-500 mt-1">
調 ()
</p>
</div>
</div>
{/* Toolbar Context */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜尋單號、原因或備註..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10 h-9"
/>
{searchQuery && (
<button
onClick={() => handleSearchChange('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Warehouse Filter */}
<SearchableSelect
options={[
{ value: '', label: '所有倉庫' },
...warehouses.map(w => ({ value: w.id, label: w.name }))
]}
value={warehouseId}
onValueChange={handleWarehouseChange}
placeholder="選擇倉庫"
className="w-full md:w-[200px] h-9"
/>
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="inventory.adjust">
<Button
className="flex-1 md:flex-none button-filled-primary h-9"
onClick={() => setIsDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
調
</Button>
</Can>
</div>
</div>
</div>
{/* Table Container */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[60px] text-center font-medium text-grey-600">#</TableHead>
<TableHead className="w-[180px] 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 text-center"></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-center font-medium text-grey-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{docs.data.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-32 text-center text-grey-400">
調
</TableCell>
</TableRow>
) : (
docs.data.map((doc: Doc, index: number) => (
<TableRow
key={doc.id}
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
onClick={() => router.visit(route('inventory.adjust.show', [doc.id]))}
>
<TableCell className="text-center text-gray-500 font-medium">
{(docs.current_page - 1) * docs.per_page + index + 1}
</TableCell>
<TableCell className="font-medium text-primary-main">
{doc.doc_no}
</TableCell>
<TableCell>{doc.warehouse_name}</TableCell>
<TableCell className="text-gray-500 max-w-[200px] truncate">{doc.reason}</TableCell>
<TableCell className="text-center">{getStatusBadge(doc.status)}</TableCell>
<TableCell className="text-sm">{doc.created_by}</TableCell>
<TableCell className="text-gray-500 text-sm">{doc.created_at}</TableCell>
<TableCell className="text-gray-500 text-sm">{doc.posted_at || '-'}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
<Link href={route('inventory.adjust.show', [doc.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title={doc.status === 'posted' ? '查閱' : '編輯'}
>
{doc.status === 'posted' ? (
<Eye className="w-4 h-4" />
) : (
<Pencil className="w-4 h-4" />
)}
</Button>
</Link>
{doc.status === 'draft' && (
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={(e) => confirmDelete(doc.id, e)}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "15", value: "15" },
{ label: "30", value: "30" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[100px] h-8"
showSearch={false}
/>
<span></span>
</div>
<span className="text-sm text-gray-500"> {docs?.total || 0} </span>
</div>
<Pagination links={docs.links} />
</div>
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>調</AlertDialogTitle>
<AlertDialogDescription>
調
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* Create Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<Plus className="h-5 w-5 text-primary-main" />
調
</DialogTitle>
<DialogDescription>
調
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-6">
{/* Option 1: Scan/Select from Count Docs */}
<div className="space-y-4 p-4 rounded-xl bg-primary-lightest/50 border border-primary-light/20 shadow-sm">
<Label className="text-sm font-bold text-primary-main flex items-center gap-2">
<ClipboardCheck className="h-4 w-4" />
()
</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-3" />
<Input
placeholder="掃描盤點單號或搜尋..."
className="pl-9 h-9"
value={scanSearch}
onChange={(e) => {
setScanSearch(e.target.value);
fetchPendingCounts(e.target.value);
}}
/>
</div>
<div className="max-h-[200px] overflow-y-auto rounded-lg border-2 border-grey-3 bg-white">
{loadingPending ? (
<div className="p-8 text-center text-sm text-grey-3">...</div>
) : pendingCounts.length === 0 ? (
<div className="p-8 text-center text-sm text-grey-3">
調 ()
</div>
) : (
<div className="divide-y divide-grey-4">
{pendingCounts.map((c: any) => (
<div
key={c.id}
className="p-3 hover:bg-primary-lightest flex items-center justify-between cursor-pointer group transition-colors"
onClick={() => handleCreate(c.id)}
>
<div>
<p className="font-bold text-grey-0 group-hover:text-primary-main">{c.doc_no}</p>
<p className="text-xs text-grey-2">{c.warehouse_name} | : {c.completed_at}</p>
</div>
<Button size="sm" variant="outline" className="button-outlined-primary h-7 text-xs">
</Button>
</div>
))}
</div>
)}
</div>
</div>
<div className="relative flex items-center py-2">
<div className="flex-grow border-t border-grey-4"></div>
<span className="flex-shrink mx-4 text-xs font-semibold text-grey-3 uppercase tracking-wider"></span>
<div className="flex-grow border-t border-grey-4"></div>
</div>
{/* Option 2: Manual (Optional, though less common in this flow) */}
<div className="space-y-4 px-1">
<Label className="text-sm font-bold text-grey-0">調</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-semibold text-grey-1"></Label>
<SearchableSelect
options={warehouses.map(w => ({ value: w.id, label: w.name }))}
value={data.warehouse_id}
onValueChange={(val) => setData('warehouse_id', val)}
placeholder="選擇倉庫"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold text-grey-1">調</Label>
<Input
placeholder="例如: 報廢, 破損..."
value={data.reason}
onChange={(e) => setData('reason', e.target.value)}
className="h-9"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="button-outlined-primary" onClick={() => setIsDialogOpen(false)}></Button>
<Button
className="button-filled-primary"
disabled={processing || !data.warehouse_id || !data.reason}
onClick={() => handleCreate()}
>
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,587 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, useForm, router } from '@inertiajs/react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import { Checkbox } from "@/Components/ui/checkbox";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import { Label } from "@/Components/ui/label";
import { Save, CheckCircle, Trash2, ArrowLeft, Plus, ClipboardCheck, Package, Search } from "lucide-react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/Components/ui/dialog";
import axios from 'axios';
import { Can } from '@/Components/Permission/Can';
import { toast } from 'sonner';
interface AdjItem {
id?: string;
product_id: string;
product_name: string;
product_code: string;
batch_number: string | null;
unit: string;
qty_before: number | string;
adjust_qty: number | string;
notes: string;
}
interface AdjDoc {
id: string;
doc_no: string;
warehouse_id: string;
warehouse_name: string;
status: string;
reason: string;
remarks: string;
created_at: string;
created_by: string;
count_doc_id?: string;
count_doc_no?: string;
items: AdjItem[];
}
export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
const isDraft = doc.status === 'draft';
// Main Form using Inertia useForm
const { data, setData, put, delete: destroy, processing } = useForm({
reason: doc.reason,
remarks: doc.remarks || '',
items: doc.items || [],
action: 'save',
});
// Product Selection State
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false);
const [availableInventory, setAvailableInventory] = useState<any[]>([]);
const [loadingInventory, setLoadingInventory] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedInventory, setSelectedInventory] = useState<string[]>([]); // product_id-batch
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
useEffect(() => {
if (isProductDialogOpen) {
loadInventory();
setSelectedInventory([]); // Reset selection when opening
setSearchQuery(''); // Reset search when opening
}
}, [isProductDialogOpen]);
const loadInventory = async () => {
setLoadingInventory(true);
try {
const response = await axios.get(route('api.warehouses.inventories', doc.warehouse_id));
setAvailableInventory(response.data);
} catch (error) {
console.error("Failed to load inventory", error);
toast.error("無法載入庫存資料");
} finally {
setLoadingInventory(false);
}
};
const toggleSelect = (key: string) => {
setSelectedInventory(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const toggleSelectAll = () => {
const filtered = availableInventory.filter(inv =>
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`);
if (filteredKeys.length > 0 && filteredKeys.every(k => selectedInventory.includes(k))) {
setSelectedInventory(prev => prev.filter(k => !filteredKeys.includes(k)));
} else {
setSelectedInventory(prev => Array.from(new Set([...prev, ...filteredKeys])));
}
};
// Helper to add selected items to the main list
const handleAddSelected = () => {
if (selectedInventory.length === 0) return;
const newItems = [...data.items];
let addedCount = 0;
availableInventory.forEach(inv => {
const key = `${inv.product_id}-${inv.batch_number}`;
if (selectedInventory.includes(key)) {
// Check if already exists
const exists = newItems.find((i: any) =>
i.product_id === String(inv.product_id) &&
i.batch_number === inv.batch_number
);
if (!exists) {
newItems.push({
product_id: String(inv.product_id),
product_name: inv.product_name,
product_code: inv.product_code,
unit: inv.unit_name,
batch_number: inv.batch_number,
qty_before: inv.quantity || 0,
adjust_qty: 0,
notes: '',
});
addedCount++;
}
}
});
setData('items', newItems);
setIsProductDialogOpen(false);
if (addedCount > 0) {
toast.success(`已成功加入 ${addedCount} 個項目`);
} else {
toast.info("選取的商品已在清單中");
}
};
const removeItem = (index: number) => {
const newItems = [...data.items];
newItems.splice(index, 1);
setData('items', newItems);
};
const updateItem = (index: number, field: keyof AdjItem, value: any) => {
const newItems = [...data.items];
(newItems[index] as any)[field] = value;
setData('items', newItems);
};
const handleSave = () => {
setData('action', 'save');
put(route('inventory.adjust.update', [doc.id]), {
preserveScroll: true,
onSuccess: () => toast.success("草稿儲存成功"),
});
};
const handlePost = () => {
if (data.items.length === 0) {
toast.error('請至少加入一個調整項目');
return;
}
router.put(route('inventory.adjust.update', [doc.id]), {
...data,
action: 'post'
} as any, {
onSuccess: () => {
setIsPostDialogOpen(false);
toast.success("盤調單過帳成功");
}
});
};
const handleDelete = () => {
destroy(route('inventory.adjust.destroy', [doc.id]), {
onSuccess: () => toast.success("盤調單已刪除"),
});
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '商品與庫存管理', href: '#' },
{ label: '庫存盤調', href: route('inventory.adjust.index') },
{ label: `盤調單: ${doc.doc_no}`, href: route('inventory.adjust.show', [doc.id]), isPage: true },
]}
>
<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.adjust.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
調
</Button>
</Link>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 text-primary-main" />
調: {doc.doc_no}
</h1>
{isDraft ? (
<Badge variant="secondary" className="bg-blue-500 text-white border-none py-1 px-3">稿</Badge>
) : (
<Badge className="bg-green-500 text-white border-none py-1 px-3"></Badge>
)}
</div>
<p className="text-sm text-gray-500 mt-1 font-medium flex items-center gap-2">
: {doc.warehouse_name} <span className="mx-1">|</span>
: {doc.created_by} <span className="mx-1">|</span>
: {doc.created_at}
{doc.count_doc_id && (
<>
<span className="mx-1">|</span>
<Link
href={route('inventory.count.show', [doc.count_doc_id])}
className="flex items-center gap-1 text-primary-main hover:underline"
>
: {doc.count_doc_no}
</Link>
</>
)}
</p>
</div>
<div className="flex items-center gap-2">
{isDraft && (
<Can permission="inventory.adjust">
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={processing} className="button-outlined-error">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>調</AlertDialogTitle>
<AlertDialogDescription>
稿
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="button-filled-error"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={handleSave}
disabled={processing}
>
<Save className="w-4 h-4 mr-2" />
</Button>
<AlertDialog open={isPostDialogOpen} onOpenChange={setIsPostDialogOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="button-filled-primary"
disabled={processing || data.items.length === 0}
>
<CheckCircle className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
調調
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handlePost} className="button-filled-primary"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
)}
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
{/* Header Fields - Inline */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-2">
<div className="space-y-1">
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider font-semibold">調</Label>
{isDraft ? (
<Input
value={data.reason}
onChange={e => setData('reason', e.target.value)}
className="focus:ring-primary-main h-9"
placeholder="請輸入調整原因..."
/>
) : (
<div className="text-grey-900 font-medium py-1">{data.reason}</div>
)}
</div>
<div className="space-y-1">
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider font-semibold"></Label>
{isDraft ? (
<Input
value={data.remarks}
onChange={e => setData('remarks', e.target.value)}
className="focus:ring-primary-main h-9"
placeholder="選填備註..."
/>
) : (
<div className="text-grey-600 py-1">{data.remarks || '-'}</div>
)}
</div>
</div>
<div className="border-t pt-4"></div>
<div className="flex flex-row items-center justify-between mb-2">
<div>
<h3 className="text-lg font-semibold text-grey-900">調</h3>
<p className="text-sm text-gray-500">
</p>
</div>
{isDraft && !doc.count_doc_id && (
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="button-outlined-primary">
<Plus className="h-4 w-4 mr-2" />
調
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col p-6">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<DialogTitle className="text-xl"> ({doc.warehouse_name})</DialogTitle>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-3" />
<Input
placeholder="搜尋品名或代號..."
className="pl-9 h-9 border-2 border-grey-3 focus:ring-primary-main"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</DialogHeader>
<div className="flex-1 overflow-auto pr-1">
{loadingInventory ? (
<div className="text-center py-12">
<Package className="h-10 w-10 animate-bounce mx-auto text-gray-300 mb-2" />
<p className="text-grey-2 text-sm">...</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50/80 sticky top-0 z-10 shadow-sm">
<TableRow>
<TableHead className="w-[50px] text-center">
<Checkbox
checked={availableInventory.length > 0 && selectedInventory.length === availableInventory.length}
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="text-right font-medium text-grey-600 pr-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
const filtered = availableInventory.filter(inv =>
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase())
);
if (filtered.length === 0) {
return (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-grey-3 italic font-medium">
{searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'}
</TableCell>
</TableRow>
);
}
return filtered.map((inv) => {
const key = `${inv.product_id}-${inv.batch_number}`;
const isSelected = selectedInventory.includes(key);
return (
<TableRow
key={key}
className={`hover:bg-primary-lightest/20 cursor-pointer transition-colors ${isSelected ? 'bg-primary-lightest/40' : ''}`}
onClick={() => toggleSelect(key)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
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-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
</TableRow>
);
});
})()}
</TableBody>
</Table>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between border-t pt-4">
<div className="flex items-center gap-3">
<div className="px-3 py-1 bg-primary-lightest/50 border border-primary-light/20 rounded-full text-sm font-medium text-primary-main animate-in zoom-in duration-200">
{selectedInventory.length}
</div>
{selectedInventory.length > 0 && (
<Button
variant="ghost"
size="sm"
className="text-grey-3 hover:text-red-500 hover:bg-red-50 text-xs px-2 h-7"
onClick={() => setSelectedInventory([])}
>
</Button>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="button-outlined-primary w-24"
onClick={() => setIsProductDialogOpen(false)}
>
</Button>
<Button
className="button-filled-primary min-w-32"
disabled={selectedInventory.length === 0}
onClick={handleAddSelected}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center font-medium text-grey-600">#</TableHead>
<TableHead className="pl-4 font-medium text-grey-600"> / </TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="w-24 text-center font-medium text-grey-600"></TableHead>
<TableHead className="w-32 text-right font-medium text-grey-600">調</TableHead>
<TableHead className="w-40 text-right font-medium text-grey-600">調 (+/-)</TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
{isDraft && <TableHead className="w-[50px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={isDraft ? 8 : 7} className="h-32 text-center text-grey-400">
</TableCell>
</TableRow>
) : (
data.items.map((item, index) => (
<TableRow
key={`${item.product_id}-${item.batch_number}-${index}`}
className="group hover:bg-gray-50/50 transition-colors"
>
<TableCell className="text-center text-grey-400 font-medium">{index + 1}</TableCell>
<TableCell className="pl-4 py-3">
<div className="flex flex-col">
<span className="font-semibold text-gray-900">{item.product_name}</span>
<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-center text-grey-500">{item.unit}</TableCell>
<TableCell className="text-right font-medium text-grey-400">
{item.qty_before}
</TableCell>
<TableCell className="text-right">
{isDraft ? (
<div className="flex justify-end pr-2">
<Input
type="number"
className="text-right h-9 w-32 font-medium"
value={item.adjust_qty}
onChange={e => updateItem(index, 'adjust_qty', e.target.value)}
/>
</div>
) : (
<span className={`font-bold mr-2 ${Number(item.adjust_qty) > 0 ? 'text-green-600' : Number(item.adjust_qty) < 0 ? 'text-red-600' : 'text-gray-600'}`}>
{Number(item.adjust_qty) > 0 ? '+' : ''}{item.adjust_qty}
</span>
)}
</TableCell>
<TableCell>
{isDraft ? (
<Input
className="h-9 text-sm"
value={item.notes || ''}
onChange={e => updateItem(index, 'notes', e.target.value)}
placeholder="備註..."
/>
) : (
<span className="text-grey-600 text-sm">{item.notes || '-'}</span>
)}
</TableCell>
{isDraft && !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"
onClick={() => removeItem(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,381 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, useForm, router } from '@inertiajs/react';
import { useState, useCallback, useEffect } from 'react';
import { debounce } from "lodash";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/Components/ui/table';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Badge } from '@/Components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/Components/ui/dialog";
import { Label } from '@/Components/ui/label';
import {
Plus,
Search,
X,
ClipboardCheck,
Eye,
Pencil,
Trash2
} from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import Pagination from '@/Components/shared/Pagination';
import { Can } from '@/Components/Permission/Can';
export default function Index({ auth, 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({
warehouse_id: '',
remarks: '',
});
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all");
const [perPage, setPerPage] = useState(filters.per_page || "10");
// Sync state with props
useEffect(() => {
setSearchTerm(filters.search || "");
setWarehouseFilter(filters.warehouse_id || "all");
setPerPage(filters.per_page || "10");
}, [filters]);
// Debounced Search Handler
const debouncedSearch = useCallback(
debounce((term: string, warehouse: string) => {
router.get(
route('inventory.count.index'),
{ ...filters, search: term, warehouse_id: warehouse === "all" ? "" : warehouse },
{ preserveState: true, replace: true, preserveScroll: true }
);
}, 500),
[filters]
);
const handleSearchChange = (term: string) => {
setSearchTerm(term);
debouncedSearch(term, warehouseFilter);
};
const handleFilterChange = (value: string) => {
setWarehouseFilter(value);
router.get(
route('inventory.count.index'),
{ ...filters, warehouse_id: value === "all" ? "" : value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleClearSearch = () => {
setSearchTerm("");
router.get(
route('inventory.count.index'),
{ ...filters, search: "", warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter },
{ preserveState: true, replace: true, preserveScroll: true }
);
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route('inventory.count.index'),
{ ...filters, per_page: value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleCreate = (e) => {
e.preventDefault();
post(route('inventory.count.store'), {
onSuccess: () => {
setIsCreateDialogOpen(false);
reset();
},
});
};
const confirmDelete = (id: string) => {
setDeleteId(id);
};
const handleDelete = () => {
if (deleteId) {
destroy(route('inventory.count.destroy', [deleteId]), {
onSuccess: () => setDeleteId(null),
onError: () => setDeleteId(null),
});
}
};
const getStatusBadge = (status) => {
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>;
case 'adjusted':
return <Badge className="bg-purple-500 hover:bg-purple-600">調</Badge>;
case 'cancelled':
return <Badge variant="destructive"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '商品與庫存與管理', href: '#' },
{ label: '庫存盤點', href: route('inventory.count.index'), isPage: true },
]}
>
<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">
<ClipboardCheck className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
</div>
{/* Toolbar */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="搜尋盤點單號、備註..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10 h-9"
/>
{searchTerm && (
<button
onClick={handleClearSearch}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Warehouse Filter */}
<SearchableSelect
value={warehouseFilter}
onValueChange={handleFilterChange}
options={[
{ label: "所有倉庫", value: "all" },
...warehouses.map((w: any) => ({ label: w.name, value: w.id.toString() }))
]}
placeholder="選擇倉庫"
className="w-full md:w-[200px] h-9"
/>
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="inventory.view">
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button className="flex-1 md:flex-none button-filled-primary">
<Plus className="w-4 h-4 mr-2" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={handleCreate}>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="warehouse"></Label>
<SearchableSelect
value={data.warehouse_id}
onValueChange={(val) => setData('warehouse_id', val)}
options={warehouses.map((w: any) => ({ label: w.name, value: w.id.toString() }))}
placeholder="請選擇倉庫"
className="h-9"
/>
{errors.warehouse_id && <p className="text-red-500 text-sm">{errors.warehouse_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="remarks"></Label>
<Input
id="remarks"
className="h-9"
value={data.remarks}
onChange={(e) => setData('remarks', e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setIsCreateDialogOpen(false)}>
</Button>
<Button type="submit" className="button-filled-primary" disabled={processing || !data.warehouse_id}>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</Can>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{docs.data.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center h-24 text-gray-500">
</TableCell>
</TableRow>
) : (
docs.data.map((doc, index) => (
<TableRow key={doc.id}>
<TableCell className="text-gray-500 font-medium text-center">
{(docs.current_page - 1) * docs.per_page + index + 1}
</TableCell>
<TableCell className="font-medium text-primary-main">{doc.doc_no}</TableCell>
<TableCell>{doc.warehouse_name}</TableCell>
<TableCell>{getStatusBadge(doc.status)}</TableCell>
<TableCell className="text-gray-500 text-sm">{doc.snapshot_date}</TableCell>
<TableCell>
<span className="font-medium text-gray-700">{doc.counted_items}</span>
<span className="text-gray-400 mx-1">/</span>
<span className="text-gray-500">{doc.total_items}</span>
</TableCell>
<TableCell className="text-gray-500 text-sm">{doc.completed_at || '-'}</TableCell>
<TableCell className="text-sm">{doc.created_by}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<Can permission="inventory.view">
<Link href={route('inventory.count.show', [doc.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title={['completed', 'adjusted'].includes(doc.status) ? '查閱' : '盤點'}
>
{['completed', 'adjusted'].includes(doc.status) ? (
<Eye className="w-4 h-4 ml-0.5" />
) : (
<Pencil className="w-4 h-4 ml-0.5" />
)}
</Button>
</Link>
{!['completed', 'adjusted'].includes(doc.status) && (
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="作廢"
onClick={() => confirmDelete(doc.id)}
>
<Trash2 className="w-4 h-4 ml-0.5" />
</Button>
)}
</Can>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[100px] h-8"
showSearch={false}
/>
<span></span>
</div>
<span className="text-sm text-gray-500"> {docs.total} </span>
</div>
<Pagination links={docs.links} />
</div>
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,166 @@
import { useEffect, useRef } from 'react';
import { Head } from '@inertiajs/react';
import JsBarcode from 'jsbarcode';
interface PrintProps {
doc: {
doc_no: string;
warehouse_name: string;
snapshot_date: string;
created_at: string;
print_date: string;
created_by: string;
items: Array<{
id: string;
product_name: string;
product_code: string;
specification: string;
unit: string;
quantity: number;
counted_qty: number | null;
notes: string;
}>;
};
}
export default function Print({ doc }: PrintProps) {
const barcodeRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (barcodeRef.current) {
try {
JsBarcode(barcodeRef.current, doc.doc_no, {
format: "CODE128",
width: 2, // Thicker bars for better scanning
height: 50, // Taller
displayValue: false,
margin: 10, // Mandatory quiet zone for scanners
marginBottom: 5
});
} catch (e) {
console.error("Barcode generation failed", e);
}
}
// Delay print slightly to ensure render
const timer = setTimeout(() => {
window.print();
}, 500);
return () => clearTimeout(timer);
}, [doc.doc_no]);
return (
<div className="bg-white text-black font-sans text-sm min-h-screen">
<Head title={`列印盤點單 ${doc.doc_no}`} />
<style>{`
@media print {
@page {
size: A4 landscape;
margin: 10mm;
}
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* Hide browser default header/footer if possible (browser dependent) */
}
`}</style>
{/* Header Section */}
<div className="relative mb-6">
{/* Barcode - Top Right */}
<div className="absolute top-0 right-0 flex flex-col items-end">
<svg ref={barcodeRef}></svg>
{/* <span className="text-xs font-mono mt-1">{doc.doc_no}</span> */}
</div>
{/* Company & Title - Center */}
<div className="text-center pt-4">
<h1 className="text-2xl font-bold tracking-widest mb-2"></h1>
<h2 className="text-xl font-bold tracking-widest"></h2>
</div>
</div>
{/* Info Section */}
<div className="flex justify-between items-end mb-2 border-b-2 border-black pb-2">
<div className="space-y-1">
<div className="flex gap-4">
<span className="font-bold"></span>
<span className="font-mono font-bold">{doc.doc_no}</span>
</div>
<div className="flex gap-4">
<span className="font-bold"></span>
<span>{doc.created_at}</span>
</div>
<div className="flex gap-4">
<span className="font-bold"></span>
<span>{doc.warehouse_name}</span>
</div>
</div>
<div className="text-right">
<div className="flex gap-4">
<span className="font-bold"></span>
<span>{doc.print_date}</span>
</div>
</div>
</div>
{/* Table Section */}
<table className="w-full border-collapse border border-black mb-8">
<thead>
<tr className="bg-gray-100">
<th className="border border-black px-2 py-1 w-12 text-center"></th>
<th className="border border-black px-2 py-1 w-32 text-left"></th>
<th className="border border-black px-2 py-1 text-left"></th>
<th className="border border-black px-2 py-1 w-32 text-left"></th>
<th className="border border-black px-2 py-1 w-20 text-right"></th>
<th className="border border-black px-2 py-1 w-16 text-center"></th>
<th className="border border-black px-2 py-1 w-32 text-left"></th>
</tr>
</thead>
<tbody>
{doc.items.map((item, index) => (
<tr key={item.id}>
<td className="border border-black px-2 py-2 text-center">{index + 1}</td>
<td className="border border-black px-2 py-2 font-mono">{item.product_code}</td>
<td className="border border-black px-2 py-2">{item.product_name}</td>
<td className="border border-black px-2 py-2">{item.specification || '-'}</td>
<td className="border border-black px-2 py-2 text-right">
{item.counted_qty !== null ? Number(item.counted_qty).toFixed(2) : ''}
</td>
<td className="border border-black px-2 py-2 text-center">{item.unit || '-'}</td>
<td className="border border-black px-2 py-2">{item.notes}</td>
</tr>
))}
{/* Empty rows filler if needed, but usually not required unless strictly paging */}
</tbody>
</table>
{/* Footer Section */}
<div className="fixed bottom-0 w-full mb-4">
<div className="flex justify-between items-end px-4">
<div className="w-1/3 border-b border-black pb-1 mb-1">
<span className="font-bold"></span>
<span className="ml-2">{doc.created_by}</span>
</div>
<div className="w-1/3 border-b border-black pb-1 mb-1 mx-4">
<span className="font-bold"></span>
</div>
<div className="w-1/3 border-b border-black pb-1 mb-1">
<span className="font-bold"></span>
</div>
</div>
<div className="flex justify-between items-center text-xs mt-4 px-4">
<div>
&nbsp;&nbsp; {doc.created_by}
</div>
<div>
1 / 1
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,319 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, Link, router } from '@inertiajs/react'; // Added Link
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/Components/ui/table';
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog"
import { Can } from '@/Components/Permission/Can';
export default function Show({ doc }: any) {
// Transform items to form data structure
const { data, setData, put, delete: destroy, processing, transform } = useForm({
items: doc.items.map((item: any) => ({
id: item.id,
counted_qty: item.counted_qty,
notes: item.notes || '',
})),
action: 'save', // 'save' or 'complete'
});
// Helper to update local form data
const updateItem = (index: number, field: string, value: any) => {
const newItems = [...data.items];
newItems[index][field] = value;
setData('items', newItems);
};
const handleSubmit = (action: string) => {
transform((data) => ({
...data,
action: action,
}));
put(route('inventory.count.update', [doc.id]));
};
const handleDelete = () => {
destroy(route('inventory.count.destroy', [doc.id]));
};
const handleReopen = () => {
router.visit(route('inventory.count.reopen', [doc.id]), {
method: 'put',
});
}
const isCompleted = ['completed', 'adjusted'].includes(doc.status);
// Calculate progress
const totalItems = doc.items.length;
const countedItems = data.items.filter((i: any) => i.counted_qty !== '' && i.counted_qty !== null).length;
const progress = Math.round((countedItems / totalItems) * 100) || 0;
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '商品與庫存管理', href: '#' },
{ label: '庫存盤點', href: route('inventory.count.index') },
{ label: `盤點單: ${doc.doc_no}`, href: route('inventory.count.show', [doc.id]), isPage: true },
]}
>
<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')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 text-primary-main" />
: {doc.doc_no}
</h1>
{doc.status === 'completed' && (
<Badge className="bg-green-500 hover:bg-green-600"></Badge>
)}
{doc.status === 'adjusted' && (
<Badge className="bg-purple-500 hover:bg-purple-600">調</Badge>
)}
{doc.status === 'draft' && (
<Badge className="bg-blue-500 hover:bg-blue-600"></Badge>
)}
</div>
<p className="text-sm text-gray-500 mt-1 font-medium">
: {doc.warehouse_name} <span className="mx-2">|</span> : {doc.created_by} <span className="mx-2">|</span> : {doc.snapshot_date}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={() => window.open(route('inventory.count.print', [doc.id]), '_blank')}
>
<Printer className="w-4 h-4 mr-2" />
</Button>
{doc.status === 'completed' && (
<Can permission="inventory.adjust">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="button-outlined-error"
disabled={processing}
>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleReopen} className="bg-red-600 hover:bg-red-700"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
)}
{!isCompleted && (
<div className="flex items-center gap-2">
<Can permission="inventory.view">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={processing} className="button-outlined-error">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
variant="outline"
size="sm"
className="button-outlined-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>
)}
</div>
</div>
</div>
{!isCompleted && (
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500 font-medium">: {countedItems} / {totalItems} </span>
<span className="font-bold text-primary-main">{progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-primary-main h-2 rounded-full transition-all duration-300" style={{ width: `${progress}%` }}></div>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-lg text-grey-900"></h3>
<p className="text-sm text-grey-500">
</p>
</div>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<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 font-medium text-grey-600"></TableHead>
<TableHead className="text-right w-32 font-medium text-grey-600"></TableHead>
<TableHead className="text-right font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{doc.items.map((item: any, index: number) => {
const formItem = data.items[index];
const diff = formItem.counted_qty !== '' && formItem.counted_qty !== null
? (parseFloat(formItem.counted_qty) - item.system_qty)
: 0;
const hasDiff = Math.abs(diff) > 0.0001;
return (
<TableRow key={item.id} className={hasDiff && formItem.counted_qty !== '' ? "bg-red-50/30" : ""}>
<TableCell className="text-gray-500 font-medium text-center">
{index + 1}
</TableCell>
<TableCell className="py-3">
<div className="flex flex-col">
<span className="font-semibold text-gray-900">{item.product_name}</span>
<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-right px-1 py-3">
{isCompleted ? (
<span className="font-semibold mr-2">{item.counted_qty}</span>
) : (
<Input
type="number"
step="1"
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"
placeholder="盤點..."
/>
)}
</TableCell>
<TableCell className="text-right">
<span className={`font-bold ${!hasDiff
? 'text-gray-400'
: diff > 0
? 'text-green-600'
: 'text-red-600'
}`}>
{formItem.counted_qty !== '' && formItem.counted_qty !== null
? diff.toFixed(0)
: '-'}
</span>
</TableCell>
<TableCell className="text-sm text-gray-500">{item.unit || item.unit_name}</TableCell>
<TableCell className="px-1">
{isCompleted ? (
<span className="text-sm text-gray-600">{item.notes}</span>
) : (
<Input
value={formItem.notes}
onChange={(e) => updateItem(index, 'notes', e.target.value)}
disabled={processing}
className="h-9 text-sm"
placeholder="備註..."
/>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</div>
</AuthenticatedLayout >
);
}

View File

@@ -0,0 +1,763 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, Link } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { SearchableSelect } from '@/Components/ui/searchable-select';
import React, { useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/Components/ui/table';
import { Badge } from "@/Components/ui/badge";
import {
Search,
Trash2,
Calendar as CalendarIcon,
Save,
ArrowLeft,
Package
} from 'lucide-react';
import axios from 'axios';
import { PurchaseOrderStatus } from '@/types/purchase-order';
import { STATUS_CONFIG } from '@/constants/purchase-order';
interface BatchItem {
inventoryId: string;
batchNumber: string;
originCountry: string;
expiryDate: string | null;
quantity: number;
}
// 待進貨採購單 Item 介面
interface PendingPOItem {
id: number;
product_id: number;
product_name: string;
product_code: string;
unit: string;
quantity: number;
received_quantity: number;
remaining: number;
unit_price: number;
batchMode?: 'existing' | 'new';
originCountry?: string; // For new batch generation
}
// 待進貨採購單介面
interface PendingPO {
id: number;
code: string;
status: PurchaseOrderStatus;
vendor_id: number;
vendor_name: string;
warehouse_id: number | null;
order_date: string;
items: PendingPOItem[];
}
// 廠商介面
interface Vendor {
id: number;
name: string;
code: string;
}
interface Props {
warehouses: { id: number; name: string; type: string }[];
pendingPurchaseOrders: PendingPO[];
vendors: Vendor[];
}
export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, vendors }: Props) {
const [selectedPO, setSelectedPO] = useState<PendingPO | null>(null);
const [selectedVendor, setSelectedVendor] = useState<Vendor | null>(null);
const [isSearching, setIsSearching] = useState(false);
// Manual Product Search States
const [productSearch, setProductSearch] = useState('');
const [foundProducts, setFoundProducts] = useState<any[]>([]);
const { data, setData, post, processing, errors } = useForm({
type: 'standard', // 'standard', 'miscellaneous', 'other'
warehouse_id: '',
purchase_order_id: '',
vendor_id: '',
received_date: new Date().toISOString().split('T')[0],
remarks: '',
items: [] as any[],
});
// 搜尋商品 API用於雜項入庫/其他類型)
const searchProducts = async () => {
if (!productSearch) return;
setIsSearching(true);
try {
const response = await axios.get(route('goods-receipts.search-products'), {
params: { query: productSearch },
});
setFoundProducts(response.data);
} catch (error) {
console.error('Failed to search products', error);
} finally {
setIsSearching(false);
}
};
// 選擇採購單
const handleSelectPO = (po: PendingPO) => {
setSelectedPO(po);
// 將採購單項目轉換為進貨單項目,預填剩餘可收貨量
const pendingItems = po.items.map((item) => ({
product_id: item.product_id,
purchase_order_item_id: item.id,
product_name: item.product_name,
sku: item.product_code,
unit: item.unit,
quantity_ordered: item.quantity,
quantity_received_so_far: item.received_quantity,
quantity_received: item.remaining, // 預填剩餘量
unit_price: item.unit_price,
batch_number: '',
batchMode: 'new',
originCountry: 'TW',
expiry_date: '',
}));
setData((prev) => ({
...prev,
purchase_order_id: po.id.toString(),
vendor_id: po.vendor_id.toString(),
warehouse_id: po.warehouse_id ? po.warehouse_id.toString() : prev.warehouse_id,
items: pendingItems,
}));
};
// 選擇廠商(雜項入庫/其他)
const handleSelectVendor = (vendorId: string) => {
const vendor = vendors.find(v => v.id.toString() === vendorId);
if (vendor) {
setSelectedVendor(vendor);
setData('vendor_id', vendor.id.toString());
}
};
const handleAddProduct = (product: any) => {
const newItem = {
product_id: product.id,
product_name: product.name,
sku: product.code,
quantity_received: 0,
unit_price: product.price || 0,
batch_number: '',
batchMode: 'new',
originCountry: 'TW',
expiry_date: '',
};
setData('items', [...data.items, newItem]);
setFoundProducts([]);
setProductSearch('');
};
const removeItem = (index: number) => {
const newItems = [...data.items];
newItems.splice(index, 1);
setData('items', newItems);
};
const updateItem = (index: number, field: string, value: any) => {
const newItems = [...data.items];
newItems[index] = { ...newItems[index], [field]: value };
setData('items', newItems);
};
// Generate batch preview (Added)
const getBatchPreview = (productId: number, productCode: string, country: string, dateStr: string) => {
if (!productCode || !productId) return "--";
try {
const datePart = dateStr.includes('T') ? dateStr.split('T')[0] : dateStr;
const [yyyy, mm, dd] = datePart.split('-');
const dateFormatted = `${yyyy}${mm}${dd}`;
const seqKey = `${productId}-${country}-${datePart}`;
// Handle sequence. Note: nextSequences values are numbers.
const seq = nextSequences[seqKey]?.toString().padStart(2, '0') || "01";
return `${productCode}-${country}-${dateFormatted}-${seq}`;
} catch (e) {
return "--";
}
};
// 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}`;
try {
const today = new Date().toISOString().split('T')[0];
const targetDate = dateStr || data.received_date || today;
// Adjust API endpoint to match AddInventory logic
// Assuming GoodsReceiptController or existing WarehouseController can handle this.
// Using the same endpoint as AddInventory: /api/warehouses/{id}/inventory/batches/{productId}
const response = await axios.get(
`/api/warehouses/${data.warehouse_id}/inventory/batches/${productId}`,
{
params: {
origin_country: country,
arrivalDate: targetDate
}
}
);
if (response.data) {
// Update existing batches list
if (response.data.batches) {
setBatchesCache(prev => ({
...prev,
[cacheKey]: response.data.batches
}));
}
// Update next sequence for new batch generation
if (response.data.nextSequence !== undefined) {
const seqKey = `${productId}-${country}-${targetDate}`;
setNextSequences(prev => ({
...prev,
[seqKey]: parseInt(response.data.nextSequence)
}));
}
}
} catch (error) {
console.error("Failed to fetch batches", error);
}
};
// Trigger batch fetch when relevant fields change
useEffect(() => {
data.items.forEach(item => {
if (item.product_id && data.warehouse_id) {
const country = item.originCountry || 'TW';
const date = data.received_date;
fetchProductBatches(item.product_id, country, date);
}
});
}, [data.items.length, data.warehouse_id, data.received_date, JSON.stringify(data.items.map(i => i.originCountry))]);
useEffect(() => {
data.items.forEach((item, index) => {
if (item.batchMode === 'new' && item.originCountry && data.received_date) {
const country = item.originCountry;
// Use date from form or today
const dateStr = data.received_date || new Date().toISOString().split('T')[0];
const seqKey = `${item.product_id}-${country}-${dateStr}`;
const seq = nextSequences[seqKey]?.toString().padStart(3, '0') || '001';
// Only generate if we have a sequence (or default)
// Note: fetch might not have returned yet, so seq might be default 001 until fetch updates nextSequences
const datePart = dateStr.replace(/-/g, '');
const generatedBatch = `${item.sku}-${country}-${datePart}-${seq}`;
if (item.batch_number !== generatedBatch) {
// Update WITHOUT triggering re-render loop
// Need a way to update item silently or check condition carefully
// Using setBatchNumber might trigger this effect again but value will be same.
const newItems = [...data.items];
newItems[index].batch_number = generatedBatch;
setData('items', newItems);
}
}
});
}, [nextSequences, JSON.stringify(data.items.map(i => ({ m: i.batchMode, c: i.originCountry, s: i.sku, p: i.product_id }))), data.received_date]);
const submit = (e: React.FormEvent) => {
e.preventDefault();
post(route('goods-receipts.store'));
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '供應鏈管理', href: '#' },
{ label: '進貨單管理', href: route('goods-receipts.index') },
{ label: '新增進貨單', href: route('goods-receipts.create'), isPage: true },
]}
>
<Head title="新增進貨單" />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
<Link href={route('goods-receipts.index')}>
<Button variant="outline" className="gap-2 mb-4 w-fit">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="mb-4">
<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>
<div className="space-y-6">
{/* Step 0: Select Type */}
<div className="bg-white rounded-lg border shadow-sm p-6">
<Label className="text-sm font-bold mb-3 block"></Label>
<div className="flex gap-4">
{[
{ id: 'standard', label: '標準採購', desc: '從採購單帶入' },
{ id: 'miscellaneous', label: '雜項入庫', desc: '非採購之入庫' },
{ id: 'other', label: '其他', desc: '其他原因入庫' },
].map((t) => (
<button
key={t.id}
onClick={() => {
setData((prev) => ({
...prev,
type: t.id,
purchase_order_id: '',
items: [],
vendor_id: t.id === 'standard' ? prev.vendor_id : '',
}));
setSelectedPO(null);
if (t.id !== 'standard') setSelectedVendor(null);
}}
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all ${data.type === t.id
? 'border-primary-main bg-primary-main/5'
: 'border-gray-100 hover:border-gray-200'
}`}
>
<div className={`font-bold ${data.type === t.id ? 'text-primary-main' : 'text-gray-700'}`}>
{t.label}
</div>
<div className="text-xs text-gray-500">{t.desc}</div>
</button>
))}
</div>
</div>
{/* Step 1: Source Selection */}
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${(data.type === 'standard' ? !!selectedPO : !!selectedVendor)
? 'bg-green-500 text-white shadow-sm' : 'bg-primary-main text-white shadow-sm'}`}>
{(data.type === 'standard' ? selectedPO : selectedVendor) ? '✓' : '1'}
</div>
<h2 className="text-lg font-bold text-gray-800">
{data.type === 'standard' ? '選擇來源採購單' : '選擇供應商'}
</h2>
</div>
<div className="p-6">
{data.type === 'standard' ? (
!selectedPO ? (
<div className="space-y-4">
<Label className="text-sm font-medium text-gray-700"></Label>
{pendingPurchaseOrders.length === 0 ? (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingPurchaseOrders.map((po) => (
<TableRow key={po.id} className="hover:bg-gray-50/50">
<TableCell className="font-medium text-primary-main">{po.code}</TableCell>
<TableCell>{po.vendor_name}</TableCell>
<TableCell className="text-center">
<Badge variant={STATUS_CONFIG[po.status]?.variant || 'outline'}>
{STATUS_CONFIG[po.status]?.label || po.status}
</Badge>
</TableCell>
<TableCell className="text-center text-gray-600">
{po.items.length}
</TableCell>
<TableCell className="text-center">
<Button size="sm" onClick={() => handleSelectPO(po)} className="button-filled-primary">
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
) : (
<div className="flex items-center justify-between bg-primary-main/5 p-4 rounded-xl border border-primary-main/20">
<div className="flex gap-8">
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-primary-main">{selectedPO.code}</span>
</div>
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-gray-800">{selectedPO.vendor_name}</span>
</div>
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-gray-800">{selectedPO.items.length} </span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
</Button>
</div>
)
) : (
!selectedVendor ? (
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<SearchableSelect
value=""
onValueChange={handleSelectVendor}
options={vendors.map(v => ({
label: `${v.name} (${v.code})`,
value: v.id.toString()
}))}
placeholder="選擇供應商..."
searchPlaceholder="搜尋供應商..."
className="h-9 w-full max-w-md"
/>
</div>
{vendors.length === 0 && (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
</div>
)}
</div>
) : (
<div className="flex items-center justify-between bg-primary-main/5 p-4 rounded-xl border border-primary-main/20">
<div className="flex gap-8">
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-primary-main">{selectedVendor.name}</span>
</div>
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-gray-800">{selectedVendor.code}</span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500">
</Button>
</div>
)
)}
</div>
</div>
{/* Step 2: Details & Items */}
{((data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
<div className="bg-white rounded-lg border shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary-main text-white flex items-center justify-center font-bold text-sm shadow-sm">2</div>
<h2 className="text-lg font-bold text-gray-800"></h2>
</div>
<div className="p-6 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label htmlFor="warehouse_id"> <span className="text-red-500">*</span></Label>
<Select
value={data.warehouse_id}
onValueChange={(val) => setData('warehouse_id', val)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="選擇倉庫" />
</SelectTrigger>
<SelectContent>
{warehouses.map(w => (
<SelectItem key={w.id} value={w.id.toString()}>{w.name} ({w.type})</SelectItem>
))}
</SelectContent>
</Select>
{errors.warehouse_id && <p className="text-red-500 text-xs">{errors.warehouse_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="received_date"> <span className="text-red-500">*</span></Label>
<div className="relative">
<CalendarIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.received_date}
onChange={(e) => setData('received_date', e.target.value)}
className="pl-9 h-9 block w-full"
/>
</div>
{errors.received_date && <p className="text-red-500 text-xs">{errors.received_date}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="remarks"></Label>
<Input
value={data.remarks}
onChange={(e) => setData('remarks', e.target.value)}
className="h-9"
placeholder="選填..."
/>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-bold text-gray-700"></h3>
{data.type !== 'standard' && (
<div className="flex gap-2 items-center">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜尋商品加入..."
value={productSearch}
onChange={(e) => setProductSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && searchProducts()}
className="h-9 w-64 pl-9"
/>
{foundProducts.length > 0 && (
<div className="absolute top-10 left-0 w-full bg-white border rounded-lg shadow-xl z-50 max-h-60 overflow-y-auto">
{foundProducts.map(p => (
<button
key={p.id}
onClick={() => handleAddProduct(p)}
className="w-full text-left p-3 hover:bg-gray-50 border-b last:border-0 flex flex-col"
>
<span className="font-bold text-sm">{p.name}</span>
<span className="text-xs text-gray-500">{p.code}</span>
</button>
))}
</div>
)}
</div>
<Button onClick={searchProducts} disabled={isSearching} size="sm" className="button-filled-primary h-9">
</Button>
</div>
)}
</div>
{/* Calculated Totals for usage in Table Footer or Summary */}
{(() => {
const subTotal = data.items.reduce((acc, item) => {
const qty = parseFloat(item.quantity_received) || 0;
const price = parseFloat(item.unit_price) || 0;
return acc + (qty * price);
}, 0);
const taxAmount = Math.round(subTotal * 0.05);
const grandTotal = subTotal + taxAmount;
return (
<>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50/50">
<TableRow>
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[120px]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[200px]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-gray-400 italic">
</TableCell>
</TableRow>
) : (
data.items.map((item, index) => {
const errorKey = `items.${index}.quantity_received` as keyof typeof errors;
const itemTotal = (parseFloat(item.quantity_received || 0) * parseFloat(item.unit_price || 0));
return (
<TableRow key={index} className="hover:bg-gray-50/50 text-sm">
{/* Product Info */}
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-gray-900">{item.product_name}</span>
<span className="text-xs text-gray-500">{item.sku}</span>
</div>
</TableCell>
{/* Total Quantity */}
<TableCell className="text-center">
<span className="text-gray-500 text-sm">
{Math.round(item.quantity_ordered)}
</span>
</TableCell>
{/* Remaining */}
<TableCell className="text-center">
<span className="text-gray-900 font-medium text-sm">
{Math.round(item.quantity_ordered - item.quantity_received_so_far)}
</span>
</TableCell>
{/* Received Quantity */}
<TableCell>
<Input
type="number"
step="1"
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' : ''}`}
/>
{(errors as any)[errorKey] && (
<p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p>
)}
</TableCell>
{/* Batch Settings */}
<TableCell>
<div className="flex gap-2 items-center">
<Input
value={item.originCountry || 'TW'}
onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))}
placeholder="產地"
maxLength={2}
className="w-16 text-center px-1"
/>
<div className="flex-1 text-sm font-mono bg-gray-50 px-3 py-2 rounded text-gray-600 truncate">
{getBatchPreview(item.product_id, item.sku, item.originCountry || 'TW', data.received_date)}
</div>
</div>
</TableCell>
{/* Expiry Date */}
<TableCell>
<div className="relative">
<CalendarIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={item.expiry_date}
onChange={(e) => updateItem(index, 'expiry_date', e.target.value)}
className={`pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`}
disabled={item.batchMode === 'existing'}
/>
</div>
</TableCell>
{/* Subtotal */}
<TableCell className="text-right font-medium">
${itemTotal.toLocaleString()}
</TableCell>
{/* Actions */}
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeItem(index)}
className="hover:bg-red-50 hover:text-red-600 h-8 w-8"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="mt-6 flex justify-end">
<div className="w-full max-w-sm bg-primary/5 px-6 py-4 rounded-xl border border-primary/10 flex flex-col gap-3">
<div className="flex justify-between items-center w-full">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-lg font-bold text-gray-700">${subTotal.toLocaleString()}</span>
</div>
<div className="flex justify-between items-center w-full">
<span className="text-sm text-gray-500 font-medium"> (5%)</span>
<span className="text-lg font-bold text-gray-700">${taxAmount.toLocaleString()}</span>
</div>
<div className="h-px bg-primary/10 w-full my-1"></div>
<div className="flex justify-between items-end w-full">
<span className="text-sm text-gray-500 font-medium mb-1"></span>
<span className="text-2xl font-black text-primary">
${grandTotal.toLocaleString()}
</span>
</div>
</div>
</div>
</>
);
})()}
</div>
</div>
</div>
)}
</div>
{/* Bottom Action Bar */}
<div className="flex items-center justify-end gap-4 py-6 border-t border-gray-100 mt-6">
<Button variant="ghost" className="h-11 px-6 text-gray-500 hover:text-gray-700" onClick={() => window.history.back()}>
</Button>
<Button
size="lg"
className="button-filled-primary px-12 h-14 rounded-xl shadow-lg text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
onClick={submit}
disabled={processing || (data.type === 'standard' ? !selectedPO : !selectedVendor)}
>
<Save className="mr-2 h-5 w-5" />
{processing ? '處理中...' : '確認進貨'}
</Button>
</div>
</div>
</AuthenticatedLayout >
);
}

View File

@@ -0,0 +1,328 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Plus, Search, FileText, RotateCcw, Calendar, ChevronDown, ChevronUp } from 'lucide-react';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import { SearchableSelect } from '@/Components/ui/searchable-select';
import Pagination from '@/Components/shared/Pagination';
import { useState, useEffect } from 'react';
import { Can } from '@/Components/Permission/Can';
import { getDateRange } from '@/utils/format';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import GoodsReceiptTable from '@/Components/Inventory/GoodsReceiptTable';
interface Warehouse {
id: number;
name: string;
type: string;
}
interface Filters {
search?: string;
status?: string;
warehouse_id?: string;
date_start?: string;
date_end?: string;
per_page?: string;
}
interface Props {
receipts: any;
filters: Filters;
warehouses: Warehouse[];
}
export default function GoodsReceiptIndex({ receipts, filters, warehouses }: Props) {
const [search, setSearch] = useState(filters.search || '');
const [status, setStatus] = useState(filters.status || 'all');
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || 'all');
const [dateStart, setDateStart] = useState(filters.date_start || '');
const [dateEnd, setDateEnd] = useState(filters.date_end || '');
const [perPage, setPerPage] = useState(filters.per_page || '10');
const [dateRangeType, setDateRangeType] = useState('custom');
// Advanced Filter Toggle
const [showAdvanced, setShowAdvanced] = useState(
!!(filters.date_start || filters.date_end)
);
// Sync filters from props
useEffect(() => {
setSearch(filters.search || '');
setStatus(filters.status || 'all');
setWarehouseId(filters.warehouse_id || 'all');
setDateStart(filters.date_start || '');
setDateEnd(filters.date_end || '');
setPerPage(filters.per_page || '10');
}, [filters]);
const handleFilter = () => {
router.get(route('goods-receipts.index'), {
search,
status: status !== 'all' ? status : undefined,
warehouse_id: warehouseId !== 'all' ? warehouseId : undefined,
date_start: dateStart || undefined,
date_end: dateEnd || undefined,
per_page: perPage,
}, { preserveState: true, replace: true });
};
const handleReset = () => {
setSearch('');
setStatus('all');
setWarehouseId('all');
setDateStart('');
setDateEnd('');
setDateRangeType('custom');
setPerPage('10');
router.get(route('goods-receipts.index'), {}, { preserveState: false });
};
const handleDateRangeChange = (type: string) => {
setDateRangeType(type);
if (type === 'custom') return;
const { start, end } = getDateRange(type);
setDateStart(start);
setDateEnd(end);
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(route('goods-receipts.index'), {
search,
status: status !== 'all' ? status : undefined,
warehouse_id: warehouseId !== 'all' ? warehouseId : undefined,
date_start: dateStart || undefined,
date_end: dateEnd || undefined,
per_page: value,
}, { preserveState: true, preserveScroll: true, replace: true });
};
const statusOptions = [
{ label: '全部狀態', value: 'all' },
{ label: '已完成', value: 'completed' },
{ label: '處理中', value: 'processing' },
];
const warehouseOptions = [
{ label: '全部倉庫', value: 'all' },
...warehouses.map(w => ({ label: w.name, value: w.id.toString() }))
];
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '供應鏈管理', href: '#' },
{ label: '進貨單管理', href: route('goods-receipts.index'), isPage: true },
]}
>
<Head title="進貨單管理" />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header Section */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<FileText className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
<Can permission="goods_receipts.create">
<Link href={route('goods-receipts.create')}>
<Button className="button-filled-primary">
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
</Can>
</div>
{/* Filter Bar */}
<div className="bg-white p-5 rounded-lg shadow-sm border border-gray-200 mb-6">
{/* Row 1: Search, Status, Warehouse */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 mb-4">
<div className="md:col-span-4 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>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-9">
<SelectValue placeholder="選擇狀態" />
</SelectTrigger>
<SelectContent>
{statusOptions.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="md:col-span-4 space-y-1">
<Label className="text-xs font-medium text-grey-1"></Label>
<SearchableSelect
value={warehouseId}
onValueChange={setWarehouseId}
options={warehouseOptions}
placeholder="選擇倉庫"
className="w-full h-9"
showSearch={warehouses.length > 10}
/>
</div>
</div>
{/* Row 2: Date Filters (Collapsible) */}
{showAdvanced && (
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end animate-in fade-in slide-in-from-top-2 duration-200">
<div className="md:col-span-6 space-y-2">
<Label className="text-xs font-medium text-grey-1"></Label>
<div className="flex flex-wrap gap-2">
{[
{ label: "今日", value: "today" },
{ label: "昨日", value: "yesterday" },
{ label: "本週", value: "this_week" },
{ label: "本月", value: "this_month" },
{ label: "上月", value: "last_month" },
].map((opt) => (
<Button
key={opt.value}
size="sm"
onClick={() => handleDateRangeChange(opt.value)}
className={
dateRangeType === opt.value
? 'button-filled-primary h-9 px-4 shadow-sm'
: 'button-outlined-primary h-9 px-4 bg-white'
}
>
{opt.label}
</Button>
))}
</div>
</div>
<div className="md:col-span-6">
<div className="grid grid-cols-2 gap-4 items-end">
<div className="space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={dateStart}
onChange={(e) => {
setDateStart(e.target.value);
setDateRangeType('custom');
}}
className="pl-9 block w-full h-9 bg-white"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={dateEnd}
onChange={(e) => {
setDateEnd(e.target.value);
setDateRangeType('custom');
}}
className="pl-9 block w-full h-9 bg-white text-left"
/>
</div>
</div>
</div>
</div>
</div>
)}
<div className="flex items-center justify-end border-t border-gray-100 pt-5 gap-3 mt-4">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="mr-auto text-gray-500 hover:text-gray-900 h-9"
>
{showAdvanced ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
{(dateStart || dateEnd) && (
<span className="ml-2 w-2 h-2 rounded-full bg-primary-main" />
)}
</>
)}
</Button>
<Button
variant="outline"
onClick={handleReset}
className="flex items-center gap-2 button-outlined-primary h-9"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
onClick={handleFilter}
className="flex items-center gap-2 button-filled-primary h-9 px-6"
>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
{/* Table Section */}
<GoodsReceiptTable receipts={receipts.data} />
{/* Pagination */}
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[100px] h-8"
showSearch={false}
/>
<span></span>
</div>
<Pagination links={receipts.links} />
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,221 @@
/**
* 查看進貨單詳情頁面
*/
import { ArrowLeft, Package } from "lucide-react";
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link } from "@inertiajs/react";
import GoodsReceiptStatusBadge from "@/Components/Inventory/GoodsReceiptStatusBadge";
import CopyButton from "@/Components/shared/CopyButton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { formatCurrency, formatDate, formatDateTime } from "@/utils/format";
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
interface GoodsReceiptItem {
id: number;
product_id: number;
product: {
id: number;
name: string;
code: string;
baseUnit?: {
name: string;
};
};
quantity_received: string | number;
unit_price: string | number;
total_amount: string | number;
batch_number?: string;
expiry_date?: string;
}
interface GoodsReceipt {
id: number;
code: string;
type: string;
received_date: string;
status: string;
remark?: string;
warehouse?: {
name: string;
};
vendor?: {
name: string;
};
items: GoodsReceiptItem[];
items_sum_total_amount: number;
created_at: string;
}
interface Props {
receipt: GoodsReceipt;
}
export default function ViewGoodsReceiptPage({ receipt }: Props) {
const typeMap: Record<string, string> = {
standard: "標準採購進貨",
miscellaneous: "雜項入庫",
other: "其他入庫",
};
return (
<AuthenticatedLayout breadcrumbs={getShowBreadcrumbs("goodsReceipts", `詳情 (#${receipt.code})`)}>
<Head title={`進貨單詳情 - ${receipt.code}`} />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
<Link href="/goods-receipts">
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<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">{receipt.code}</p>
</div>
<div className="flex items-center gap-3">
<GoodsReceiptStatusBadge status={receipt.status} />
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-8">
{/* 基本資訊卡片 */}
<div className="bg-white rounded-lg border shadow-sm p-6">
<h2 className="text-lg font-bold text-gray-900 mb-6 border-b pb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<div className="flex items-center gap-1.5">
<span className="font-mono font-medium text-gray-900">{receipt.code}</span>
<CopyButton text={receipt.code} label="複製單號" />
</div>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{typeMap[receipt.type] || receipt.type}</span>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{receipt.warehouse?.name || "-"}</span>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{receipt.vendor?.name || "-"}</span>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{formatDate(receipt.received_date)}</span>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{formatDateTime(receipt.created_at)}</span>
</div>
</div>
{receipt.remark && (
<div className="mt-8 pt-6 border-t border-gray-100">
<span className="text-sm text-gray-500 block mb-2"></span>
<p className="text-sm text-gray-700 bg-gray-50 p-4 rounded-lg leading-relaxed">
{receipt.remark}
</p>
</div>
)}
</div>
{/* 品項清單卡片 */}
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<div className="p-6 border-b border-gray-100">
<h2 className="text-lg font-bold text-gray-900"></h2>
</div>
<div className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50/50">
<TableHead className="w-[80px] text-center">#</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{receipt.items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-gray-500">
</TableCell>
</TableRow>
) : (
receipt.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center text-gray-500">{index + 1}</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-gray-900">{item.product.name}</span>
<span className="text-xs text-gray-500 font-mono">{item.product.code}</span>
</div>
</TableCell>
<TableCell className="text-right font-medium">
{Number(item.quantity_received).toLocaleString()}
</TableCell>
<TableCell className="text-center">
{item.product.baseUnit?.name || "個"}
</TableCell>
<TableCell className="text-right">
{formatCurrency(Number(item.unit_price))}
</TableCell>
<TableCell className="text-right font-bold text-primary">
{formatCurrency(Number(item.total_amount))}
</TableCell>
<TableCell>
<span className="text-sm font-mono">{item.batch_number || "-"}</span>
</TableCell>
<TableCell>
<span className="text-sm">{item.expiry_date ? formatDate(item.expiry_date) : "-"}</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 總計 */}
<div className="p-6 border-t border-gray-100 flex justify-end">
<div className="w-full max-w-xs bg-gray-50/50 px-6 py-4 rounded-xl border border-gray-100 flex flex-col gap-3">
<div className="flex justify-between items-end w-full">
<span className="text-sm text-gray-500 font-medium mb-1"></span>
<span className="text-2xl font-black text-primary">
{formatCurrency(receipt.items_sum_total_amount)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,399 @@
import { useState, useCallback, useEffect } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { debounce } from "lodash";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogDescription,
} from "@/Components/ui/dialog";
import { Label } from "@/Components/ui/label";
import {
Plus,
Search,
ArrowLeftRight,
Loader2,
Eye,
Pencil,
Trash2,
X,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import Pagination from "@/Components/shared/Pagination";
import { toast } from "sonner";
import { Can } from '@/Components/Permission/Can';
export default function Index({ warehouses, orders, filters }: any) {
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all");
const [perPage, setPerPage] = useState(filters.per_page || "10");
// Sync state with props
useEffect(() => {
setSearchTerm(filters.search || "");
setWarehouseFilter(filters.warehouse_id || "all");
setPerPage(filters.per_page || "10");
}, [filters]);
// Create Dialog State
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [sourceWarehouseId, setSourceWarehouseId] = useState("");
const [targetWarehouseId, setTargetWarehouseId] = useState("");
const [creating, setCreating] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
// Debounced Search Handler
const debouncedSearch = useCallback(
debounce((term: string, warehouse: string) => {
router.get(
route('inventory.transfer.index'),
{ ...filters, search: term, warehouse_id: warehouse === "all" ? "" : warehouse },
{ preserveState: true, replace: true, preserveScroll: true }
);
}, 500),
[filters]
);
const handleSearchChange = (term: string) => {
setSearchTerm(term);
debouncedSearch(term, warehouseFilter);
};
const handleFilterChange = (value: string) => {
setWarehouseFilter(value);
router.get(
route('inventory.transfer.index'),
{ ...filters, warehouse_id: value === "all" ? "" : value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleClearSearch = () => {
setSearchTerm("");
router.get(
route('inventory.transfer.index'),
{ ...filters, search: "", warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter },
{ preserveState: true, replace: true, preserveScroll: true }
);
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route('inventory.transfer.index'),
{ ...filters, per_page: value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleCreate = () => {
if (!sourceWarehouseId) {
toast.error("請選擇來源倉庫");
return;
}
if (!targetWarehouseId) {
toast.error("請選擇目的倉庫");
return;
}
if (sourceWarehouseId === targetWarehouseId) {
toast.error("來源與目的倉庫不能相同");
return;
}
setCreating(true);
router.post(route('inventory.transfer.store'), {
from_warehouse_id: sourceWarehouseId,
to_warehouse_id: targetWarehouseId
}, {
onFinish: () => setCreating(false),
onSuccess: () => {
setIsCreateOpen(false);
setSourceWarehouseId("");
setTargetWarehouseId("");
}
});
};
const confirmDelete = (id: string) => {
setDeleteId(id);
};
const handleDelete = () => {
if (deleteId) {
router.delete(route('inventory.transfer.destroy', [deleteId]), {
onSuccess: () => {
setDeleteId(null);
toast.success("已成功刪除");
},
onError: () => setDeleteId(null),
});
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <Badge variant="secondary">稿</Badge>;
case 'completed':
return <Badge className="bg-green-500 hover:bg-green-600"></Badge>;
case 'voided':
return <Badge variant="destructive"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '商品與庫存與管理', href: '#' },
{ label: '庫存調撥', href: route('inventory.transfer.index'), isPage: true },
]}
>
<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">
<ArrowLeftRight className="h-6 w-6 text-primary-main" />
調
</h1>
<p className="text-gray-500 mt-1">
調
</p>
</div>
</div>
{/* Toolbar */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="搜尋調撥單號、備註..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10 h-9"
/>
{searchTerm && (
<button
onClick={handleClearSearch}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Warehouse Filter */}
<SearchableSelect
value={warehouseFilter}
onValueChange={handleFilterChange}
options={[
{ label: "所有倉庫", value: "all" },
...warehouses.map((w: any) => ({ label: w.name, value: w.id.toString() }))
]}
placeholder="選擇倉庫"
className="w-full md:w-[200px] h-9"
/>
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="inventory.view">
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button className="flex-1 md:flex-none button-filled-primary">
<Plus className="w-4 h-4 mr-2" />
調
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>調</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label></Label>
<SearchableSelect
value={sourceWarehouseId}
onValueChange={setSourceWarehouseId}
options={warehouses.map((w: any) => ({ label: w.name, value: w.id.toString() }))}
placeholder="請選擇來源倉庫"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label></Label>
<SearchableSelect
value={targetWarehouseId}
onValueChange={setTargetWarehouseId}
options={warehouses.filter((w: any) => w.id.toString() !== sourceWarehouseId).map((w: any) => ({ label: w.name, value: w.id.toString() }))}
placeholder="請選擇目的倉庫"
className="h-9"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setIsCreateOpen(false)}>
</Button>
<Button onClick={handleCreate} className="button-filled-primary" disabled={creating || !sourceWarehouseId || !targetWarehouseId}>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Can>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center font-medium text-gray-600">#</TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.data.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center h-24 text-gray-500">
調
</TableCell>
</TableRow>
) : (
orders.data.map((order: any, index: number) => (
<TableRow
key={order.id}
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
onClick={() => router.visit(route('inventory.transfer.show', [order.id]))}
>
<TableCell className="text-center text-gray-500 font-medium">
{(orders.current_page - 1) * orders.per_page + index + 1}
</TableCell>
<TableCell className="font-medium text-primary-main">{order.doc_no}</TableCell>
<TableCell className="text-center">{getStatusBadge(order.status)}</TableCell>
<TableCell className="text-gray-700">{order.from_warehouse_name}</TableCell>
<TableCell className="text-gray-700">{order.to_warehouse_name}</TableCell>
<TableCell className="text-gray-500 text-sm">{order.created_at}</TableCell>
<TableCell className="text-gray-500 text-sm">{order.posted_at || '-'}</TableCell>
<TableCell className="text-sm">{order.created_by}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
<Can permission="inventory.view">
<Link href={route('inventory.transfer.show', [order.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title={['completed', 'voided'].includes(order.status) ? '查閱' : '編輯'}
>
{['completed', 'voided'].includes(order.status) ? (
<Eye className="w-4 h-4 ml-0.5" />
) : (
<Pencil className="w-4 h-4 ml-0.5" />
)}
</Button>
</Link>
{order.status === 'draft' && (
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => confirmDelete(order.id)}
>
<Trash2 className="w-4 h-4 ml-0.5" />
</Button>
)}
</Can>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[100px] h-8"
showSearch={false}
/>
<span></span>
</div>
<span className="text-sm text-gray-500"> {orders.total} </span>
</div>
<Pagination links={orders.links} />
</div>
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>調</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,542 @@
import { useState, useEffect } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link } from "@inertiajs/react";
import { Button } from "@/Components/ui/button";
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";
import { Checkbox } from "@/Components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/Components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Search } from "lucide-react";
import { toast } from "sonner";
import axios from "axios";
import { Can } from '@/Components/Permission/Can';
export default function Show({ order }: any) {
const [items, setItems] = useState(order.items || []);
const [remarks, setRemarks] = useState(order.remarks || "");
const [isSaving, setIsSaving] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false);
// Product Selection
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false);
const [availableInventory, setAvailableInventory] = useState<any[]>([]);
const [loadingInventory, setLoadingInventory] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedInventory, setSelectedInventory] = useState<string[]>([]); // product_id-batch
useEffect(() => {
if (isProductDialogOpen) {
loadInventory();
setSelectedInventory([]);
setSearchQuery('');
}
}, [isProductDialogOpen]);
const loadInventory = async () => {
setLoadingInventory(true);
try {
// Fetch inventory from SOURCE warehouse
const response = await axios.get(route('api.warehouses.inventories', order.from_warehouse_id));
setAvailableInventory(response.data);
} catch (error) {
console.error("Failed to load inventory", error);
toast.error("無法載入庫存資料");
} finally {
setLoadingInventory(false);
}
};
const toggleSelect = (key: string) => {
setSelectedInventory(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const toggleSelectAll = () => {
const filtered = availableInventory.filter(inv =>
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`);
if (filteredKeys.length > 0 && filteredKeys.every(k => selectedInventory.includes(k))) {
setSelectedInventory(prev => prev.filter(k => !filteredKeys.includes(k)));
} else {
setSelectedInventory(prev => Array.from(new Set([...prev, ...filteredKeys])));
}
};
const handleAddSelected = () => {
if (selectedInventory.length === 0) return;
const newItems = [...items];
let addedCount = 0;
availableInventory.forEach(inv => {
const key = `${inv.product_id}-${inv.batch_number}`;
if (selectedInventory.includes(key)) {
// Check if already added
const exists = newItems.find((i: any) =>
i.product_id === inv.product_id &&
i.batch_number === inv.batch_number
);
if (!exists) {
newItems.push({
product_id: inv.product_id,
product_name: inv.product_name,
product_code: inv.product_code,
batch_number: inv.batch_number,
unit: inv.unit_name,
quantity: 1, // Default 1
max_quantity: inv.quantity, // Max available
notes: "",
});
addedCount++;
}
}
});
setItems(newItems);
setIsProductDialogOpen(false);
if (addedCount > 0) {
toast.success(`已成功加入 ${addedCount} 個項目`);
} else {
toast.info("選取的商品已在清單中");
}
};
const handleUpdateItem = (index: number, field: string, value: any) => {
const newItems = [...items];
newItems[index][field] = value;
setItems(newItems);
};
const handleRemoveItem = (index: number) => {
const newItems = items.filter((_: any, i: number) => i !== index);
setItems(newItems);
};
const handleSave = async () => {
setIsSaving(true);
try {
await router.put(route('inventory.transfer.update', [order.id]), {
items: items,
remarks: remarks,
}, {
onSuccess: () => toast.success("儲存成功"),
onError: () => toast.error("儲存失敗,請檢查輸入"),
});
} finally {
setIsSaving(false);
}
};
const handlePost = () => {
router.put(route('inventory.transfer.update', [order.id]), {
action: 'post'
}, {
onSuccess: () => {
setIsPostDialogOpen(false);
toast.success("過帳成功");
}
});
};
const handleDelete = () => {
router.delete(route('inventory.transfer.destroy', [order.id]), {
onSuccess: () => {
setDeleteId(null);
toast.success("已成功刪除");
}
});
};
const isReadOnly = order.status !== 'draft';
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '商品與庫存管理', href: '#' },
{ label: '庫存調撥', href: route('inventory.transfer.index') },
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
]}
>
<Head title={`調撥單 ${order.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.transfer.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
調
</Button>
</Link>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<ArrowLeftRight className="h-6 w-6 text-primary-main" />
調: {order.doc_no}
</h1>
{order.status === 'completed' && <Badge className="bg-green-500 hover:bg-green-600"></Badge>}
{order.status === 'draft' && <Badge className="bg-blue-500 hover:bg-blue-600">稿</Badge>}
{order.status === 'voided' && <Badge variant="destructive"></Badge>}
</div>
<p className="text-sm text-gray-500 mt-1 font-medium">
: {order.from_warehouse_name} <ArrowLeftRight className="inline-block h-3 w-3 mx-1" /> : {order.to_warehouse_name} <span className="mx-2">|</span> : {order.created_by}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={() => window.print()}
>
<Printer className="w-4 h-4 mr-2" />
</Button>
{!isReadOnly && (
<div className="flex items-center gap-2">
<Can permission="inventory.view">
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="button-outlined-error" onClick={() => setDeleteId(order.id)}>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>調</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={handleSave}
disabled={isSaving}
>
<Save className="w-4 h-4 mr-2" />
稿
</Button>
<AlertDialog open={isPostDialogOpen} onOpenChange={setIsPostDialogOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="button-filled-primary"
disabled={items.length === 0 || isSaving}
>
<CheckCircle className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{order.from_warehouse_name}{order.to_warehouse_name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handlePost} className="bg-primary-600 hover:bg-primary-700"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
</div>
)}
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
<div className="flex items-center justify-between">
<Label className="text-gray-500 font-semibold"></Label>
</div>
{isReadOnly ? (
<div className="text-gray-700 p-2 bg-gray-50 rounded border text-sm min-h-[40px]">
{order.remarks || '無備註'}
</div>
) : (
<Input
value={remarks}
onChange={(e) => setRemarks(e.target.value)}
className="h-9 focus:ring-primary-main"
placeholder="填寫調撥單備註..."
/>
)}
</div>
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-lg text-grey-900">調</h3>
<p className="text-sm text-grey-500">
調{order.from_warehouse_name}
</p>
</div>
{!isReadOnly && (
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="button-outlined-primary">
<Plus className="h-4 w-4 mr-2" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col p-6">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<DialogTitle className="text-xl"> ({order.from_warehouse_name})</DialogTitle>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-3" />
<Input
placeholder="搜尋品名或代號..."
className="pl-9 h-9 border-2 border-grey-3 focus:ring-primary-main"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</DialogHeader>
<div className="flex-1 overflow-auto pr-1">
{loadingInventory ? (
<div className="text-center py-12">
<Package className="h-10 w-10 animate-bounce mx-auto text-gray-300 mb-2" />
<p className="text-grey-2 text-sm">...</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50/80 sticky top-0 z-10 shadow-sm">
<TableRow>
<TableHead className="w-[50px] text-center">
<Checkbox
checked={availableInventory.length > 0 && (() => {
const filtered = availableInventory.filter(inv =>
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`);
return filteredKeys.length > 0 && filteredKeys.every(k => selectedInventory.includes(k));
})()}
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="text-right font-medium text-grey-600 pr-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
const filtered = availableInventory.filter(inv =>
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase())
);
if (filtered.length === 0) {
return (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-grey-3 italic font-medium">
{searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'}
</TableCell>
</TableRow>
);
}
return filtered.map((inv) => {
const key = `${inv.product_id}-${inv.batch_number}`;
const isSelected = selectedInventory.includes(key);
return (
<TableRow
key={key}
className={`hover:bg-primary-lightest/20 cursor-pointer transition-colors ${isSelected ? 'bg-primary-lightest/40' : ''}`}
onClick={() => toggleSelect(key)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
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-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
</TableRow>
);
});
})()}
</TableBody>
</Table>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between border-t pt-4">
<div className="flex items-center gap-3">
<div className="px-3 py-1 bg-primary-lightest/50 border border-primary-light/20 rounded-full text-sm font-medium text-primary-main animate-in zoom-in duration-200">
{selectedInventory.length}
</div>
{selectedInventory.length > 0 && (
<Button
variant="ghost"
size="sm"
className="text-grey-3 hover:text-red-500 hover:bg-red-50 text-xs px-2 h-7"
onClick={() => setSelectedInventory([])}
>
</Button>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="button-outlined-primary w-24"
onClick={() => setIsProductDialogOpen(false)}
>
</Button>
<Button
className="button-filled-primary min-w-32"
disabled={selectedInventory.length === 0}
onClick={handleAddSelected}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<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-40 font-medium text-grey-600">調</TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
{!isReadOnly && <TableHead className="w-[50px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
</TableCell>
</TableRow>
) : (
items.map((item: any, index: number) => (
<TableRow key={index}>
<TableCell className="text-center text-gray-500 font-medium">{index + 1}</TableCell>
<TableCell className="py-3">
<div className="flex flex-col">
<span className="font-semibold text-gray-900">{item.product_name}</span>
<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-semibold text-primary-main">
{item.max_quantity} {item.unit || item.unit_name}
</TableCell>
<TableCell className="px-1 py-3">
{isReadOnly ? (
<div className="text-right font-semibold mr-2">{item.quantity}</div>
) : (
<div className="flex flex-col gap-1 items-end pr-2">
<Input
type="number"
min="0.01"
step="0.01"
value={item.quantity}
onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)}
className="h-9 w-32 text-right font-medium focus:ring-primary-main"
/>
</div>
)}
</TableCell>
<TableCell className="text-sm text-gray-500">{item.unit || item.unit_name}</TableCell>
<TableCell className="px-1">
{isReadOnly ? (
<span className="text-sm text-gray-600">{item.notes}</span>
) : (
<Input
value={item.notes}
onChange={(e) => handleUpdateItem(index, 'notes', e.target.value)}
placeholder="備註..."
className="h-9 text-sm"
/>
)}
</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)}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -22,6 +22,7 @@ export interface Category {
export interface Product {
id: string;
code: string;
barcode?: string;
name: string;
categoryId: number;
category?: Category;

View File

@@ -4,11 +4,11 @@
*/
import { useState, useEffect } from "react";
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar, AlertCircle } from 'lucide-react';
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm } from "@inertiajs/react";
import toast, { Toaster } from 'react-hot-toast';
import { toast } from "sonner";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
@@ -90,12 +90,17 @@ export default function ProductionCreate({ products, warehouses }: Props) {
const [bomItems, setBomItems] = useState<BomItem[]>([]);
// 多配方支援
const [recipes, setRecipes] = useState<any[]>([]);
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
const { data, setData, processing, errors } = useForm({
product_id: "",
warehouse_id: "",
output_quantity: "",
output_batch_number: "",
output_box_count: "",
// 移除 Box Count UI
// 移除相關邏輯
production_date: new Date().toISOString().split('T')[0],
expiry_date: "",
remark: "",
@@ -244,34 +249,116 @@ export default function ProductionCreate({ products, warehouses }: Props) {
})));
}, [bomItems]);
// 自動產生成品批號(當選擇商品或日期變動時)
// 應用配方到表單 (獨立函式)
const applyRecipe = (recipe: any) => {
if (!recipe || !recipe.items) return;
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); // 保持精度
return {
inventory_id: "",
quantity_used: String(calculatedQty),
unit_id: String(item.unit_id),
ui_warehouse_id: selectedWarehouse || "", // 自動帶入目前選擇的倉庫
ui_product_id: String(item.product_id),
ui_product_name: item.product_name,
ui_batch_number: "",
ui_available_qty: 0,
ui_input_quantity: String(calculatedQty),
ui_selected_unit: 'base',
ui_base_unit_name: item.unit_name,
ui_base_unit_id: item.unit_id,
ui_conversion_rate: 1,
};
});
setBomItems(newBomItems);
// 若有選倉庫,預先載入庫存資料以供選擇
if (selectedWarehouse) {
fetchWarehouseInventory(selectedWarehouse);
}
toast.success(`已自動載入配方: ${recipe.name}`, {
description: `標準產量: ${yieldQty}`
});
};
// 當手動切換配方時
useEffect(() => {
if (!selectedRecipeId) return;
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
if (targetRecipe) {
applyRecipe(targetRecipe);
}
}, [selectedRecipeId]);
// 自動產生成品批號與載入配方
useEffect(() => {
if (!data.product_id) return;
// 1. 自動產生成品批號
const product = products.find(p => String(p.id) === data.product_id);
if (!product) return;
if (product) {
const datePart = data.production_date;
const dateFormatted = datePart.replace(/-/g, '');
const originCountry = 'TW';
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
const datePart = data.production_date; // YYYY-MM-DD
const dateFormatted = datePart.replace(/-/g, '');
const originCountry = 'TW';
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
.then(res => res.json())
.then(result => {
const seq = result.nextSequence || '01';
const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`;
setData('output_batch_number', suggested);
})
.catch(() => {
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
setData('output_batch_number', suggested);
});
}
// 呼叫 API 取得下一組流水號
// 複用庫存批號 API但這裡可能沒有選 warehouse所以用第一個預設
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
// 2. 自動載入配方列表
const fetchRecipes = async () => {
try {
// 改為抓取所有配方
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
const recipesData = await res.json();
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
.then(res => res.json())
.then(result => {
const seq = result.nextSequence || '01';
const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`;
setData('output_batch_number', suggested);
})
.catch(() => {
// Fallback若 API 失敗,使用預設 01
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
setData('output_batch_number', suggested);
});
}, [data.product_id, data.production_date]);
if (Array.isArray(recipesData) && recipesData.length > 0) {
setRecipes(recipesData);
// 預設選取最新的 (第一個)
const latest = recipesData[0];
setSelectedRecipeId(String(latest.id));
} else {
// 若無配方
setRecipes([]);
setSelectedRecipeId("");
setBomItems([]); // 清空 BOM
}
} catch (e) {
console.error("Failed to fetch recipes", e);
setRecipes([]);
setBomItems([]);
}
};
fetchRecipes();
}, [data.product_id]);
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量
useEffect(() => {
if (bomItems.length > 0 && data.output_quantity) {
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾
// 但如果是剛載入inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性
}
}, [data.output_quantity]);
// 提交表單
const submit = (status: 'draft' | 'completed') => {
@@ -286,12 +373,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) {
toast.error(
<div className="flex flex-col gap-1">
<span className="font-bold"></span>
<span className="text-sm">{missingFields.join('、')}</span>
</div>
);
toast.error("請填寫必要欄位", {
description: `缺漏:${missingFields.join('、')}`
});
return;
}
}
@@ -313,12 +397,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
}, {
onError: (errors) => {
const errorCount = Object.keys(errors).length;
toast.error(
<div className="flex flex-col gap-1">
<span className="font-bold"></span>
<span className="text-sm"> {errorCount} </span>
</div>
);
toast.error("建立失敗,請檢查表單", {
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
});
}
});
};
@@ -331,7 +412,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
<Head title="建立生產單" />
<Toaster position="top-right" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="mb-6">
<Link href={route('production-orders.index')}>
@@ -394,6 +475,28 @@ export default function ProductionCreate({ products, warehouses }: Props) {
className="w-full h-9"
/>
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
{/* 配方選擇 (放在成品商品底下) */}
{recipes.length > 0 && (
<div className="pt-2">
<div className="flex justify-between items-center mb-1">
<Label className="text-xs font-medium text-grey-2">使</Label>
<span className="text-[10px] text-blue-500">
</span>
</div>
<SearchableSelect
value={selectedRecipeId}
onValueChange={setSelectedRecipeId}
options={recipes.map(r => ({
label: `${r.name} (${r.code})`,
value: String(r.id),
}))}
placeholder="選擇配方"
className="w-full h-9"
/>
</div>
)}
</div>
<div className="space-y-1">
@@ -420,15 +523,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
{errors.output_batch_number && <p className="text-red-500 text-xs mt-1">{errors.output_batch_number}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Input
value={data.output_box_count}
onChange={(e) => setData('output_box_count', e.target.value)}
placeholder="例如: 10"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>

View File

@@ -3,7 +3,7 @@
*/
import { useState, useEffect } from "react";
import { Plus, Factory, Search, RotateCcw, Eye, Pencil } from 'lucide-react';
import { Plus, Factory, Search, RotateCcw, Eye, Pencil, Trash2 } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link } from "@inertiajs/react";
@@ -254,16 +254,33 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
</Link>
</Can>
)}
<Link href={route('production-orders.show', order.id)}>
<Can permission="production_orders.view">
<Link href={route('production-orders.show', order.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="檢視"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
</Can>
<Can permission="production_orders.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="檢視"
className="button-outlined-error"
title="刪除"
onClick={() => {
if (confirm('確定要刪除此生產工單嗎?')) {
router.delete(route('production-orders.destroy', order.id));
}
}}
>
<Eye className="h-4 w-4" />
<Trash2 className="h-4 w-4" />
</Button>
</Link>
</Can>
</div>
</TableCell>
</TableRow>
@@ -296,6 +313,6 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
</div>
</div>
</div>
</AuthenticatedLayout>
</AuthenticatedLayout >
);
}

View File

@@ -0,0 +1,166 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { Loader2, Package, Calendar, Clock, BookOpen } from "lucide-react";
interface RecipeDetailModalProps {
isOpen: boolean;
onClose: () => void;
recipe: any | null; // Detailed recipe object with items
isLoading?: boolean;
}
export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: RecipeDetailModalProps) {
if (!isOpen) return null;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl 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">
</DialogTitle>
{recipe && (
<Badge variant={recipe.is_active ? "default" : "secondary"} className="text-xs font-normal">
{recipe.is_active ? "啟用中" : "已停用"}
</Badge>
)}
</div>
{/* 現代化元數據條 */}
{recipe && (
<div className="flex flex-wrap items-center gap-6 pt-2 text-sm text-gray-500">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-gray-400" />
<span className="font-medium text-gray-700">{recipe.code}</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<span> {new Date(recipe.created_at).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-400" />
<span> {new Date(recipe.updated_at).toLocaleDateString()}</span>
</div>
</div>
)}
</DialogHeader>
<div className="bg-gray-50/50 p-6 min-h-[300px]">
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary-main" />
</div>
) : recipe ? (
<div className="space-y-6">
{/* 基本資訊區塊 */}
<div className="border rounded-md overflow-hidden bg-white shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50/50 hover:bg-gray-50/50">
<TableHead className="w-[150px]"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium text-gray-700"></TableCell>
<TableCell className="text-gray-900 font-medium">{recipe.name}</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium text-gray-700"></TableCell>
<TableCell className="text-gray-900 font-medium">
<div className="flex items-center gap-2">
<span>{recipe.product?.name || '-'}</span>
<span className="text-gray-400 text-xs bg-gray-100 px-1.5 py-0.5 rounded">{recipe.product?.code}</span>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium text-gray-700"></TableCell>
<TableCell className="text-gray-900 font-medium">
{Number(recipe.yield_quantity).toLocaleString()} {recipe.product?.base_unit?.name || '份'}
</TableCell>
</TableRow>
{recipe.description && (
<TableRow>
<TableCell className="font-medium text-gray-700 align-top pt-3"></TableCell>
<TableCell className="text-gray-600 leading-relaxed py-3">
{recipe.description}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* BOM 表格區塊 */}
<div>
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2 px-1 mb-3">
<Package className="w-4 h-4 text-primary-main" />
(BOM)
</h3>
<div className="border rounded-md overflow-hidden bg-white shadow-sm">
<Table>
<TableHeader className="bg-gray-50/50">
<TableRow>
<TableHead> / </TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recipe.items?.length > 0 ? (
recipe.items.map((item: any, index: number) => (
<TableRow key={index} className="hover:bg-gray-50/50">
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="text-gray-900">{item.product?.name || 'Unknown'}</span>
<span className="text-xs text-gray-400">{item.product?.code}</span>
</div>
</TableCell>
<TableCell className="text-right font-medium text-gray-900">
{Number(item.quantity).toLocaleString()}
</TableCell>
<TableCell className="text-gray-600">
{item.unit?.name || '-'}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{item.remark || '-'}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
) : (
<div className="py-12 text-center text-gray-500"></div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -3,7 +3,7 @@
*/
import { useState, useEffect } from "react";
import { Plus, Search, RotateCcw, Pencil, Trash2, BookOpen } from 'lucide-react';
import { Plus, Search, RotateCcw, Pencil, Trash2, BookOpen, Eye } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link } from "@inertiajs/react";
@@ -14,6 +14,20 @@ 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";
import { Can } from "@/Components/Permission/Can";
import { RecipeDetailModal } from "./Components/RecipeDetailModal";
import axios from 'axios';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
interface Recipe {
id: number;
@@ -47,6 +61,11 @@ export default function RecipeIndex({ recipes, filters }: Props) {
const [search, setSearch] = useState(filters.search || "");
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
// View Modal State
const [viewRecipe, setViewRecipe] = useState<any | null>(null);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [isViewLoading, setIsViewLoading] = useState(false);
useEffect(() => {
setSearch(filters.search || "");
setPerPage(filters.per_page || "10");
@@ -83,6 +102,20 @@ export default function RecipeIndex({ recipes, filters }: Props) {
}
};
const handleView = async (id: number) => {
setIsViewModalOpen(true);
setIsViewLoading(true);
setViewRecipe(null);
try {
const response = await axios.get(route('recipes.show', id));
setViewRecipe(response.data);
} catch (error) {
console.error("Failed to load recipe details", error);
} finally {
setIsViewLoading(false);
}
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes")}>
<Head title="配方管理" />
@@ -98,12 +131,14 @@ export default function RecipeIndex({ recipes, filters }: Props) {
</p>
</div>
<div className="flex gap-2">
<Link href={route('recipes.create')}>
<Button className="gap-2 button-filled-primary">
<Plus className="h-4 w-4" />
</Button>
</Link>
<Can permission="recipes.create">
<Link href={route('recipes.create')}>
<Button className="gap-2 button-filled-primary">
<Plus className="h-4 w-4" />
</Button>
</Link>
</Can>
</div>
</div>
@@ -157,7 +192,7 @@ export default function RecipeIndex({ recipes, filters }: Props) {
<TableHead className="text-right"></TableHead>
<TableHead className="text-center w-[100px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="text-center w-[120px]"></TableHead>
<TableHead className="text-center w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -207,25 +242,61 @@ export default function RecipeIndex({ recipes, filters }: Props) {
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<Link href={route('recipes.edit', recipe.id)}>
<Can permission="recipes.view">
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
title="查看明細"
onClick={() => handleView(recipe.id)}
>
<Pencil className="h-4 w-4" />
<Eye className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(recipe.id)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
title="刪除"
>
<Trash2 className="h-4 w-4" />
</Button>
</Can>
<Can permission="recipes.edit">
<Link href={route('recipes.edit', recipe.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
</Can>
<Can permission="recipes.delete">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{recipe.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(recipe.id)}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
</div>
</TableCell>
</TableRow>
@@ -257,6 +328,13 @@ export default function RecipeIndex({ recipes, filters }: Props) {
<Pagination links={recipes.links} />
</div>
</div>
<RecipeDetailModal
isOpen={isViewModalOpen}
onClose={() => setIsViewModalOpen(false)}
recipe={viewRecipe}
isLoading={isViewLoading}
/>
</div>
</AuthenticatedLayout>
);

View File

@@ -146,7 +146,8 @@ export default function CreatePurchaseOrder({
if (order) {
router.put(`/purchase-orders/${order.id}`, data, {
onSuccess: () => toast.success("採購單已更新"),
onSuccess: () => { },//toast.success("採購單已更新"),
onError: (errors) => {
// 顯示更詳細的錯誤訊息
if (errors.items) {
@@ -161,7 +162,8 @@ export default function CreatePurchaseOrder({
});
} else {
router.post("/purchase-orders", data, {
onSuccess: () => toast.success("採購單已成功建立"),
onSuccess: () => { },//toast.success("採購單已成功建立"),
onError: (errors) => {
if (errors.items) {
toast.error("商品資料有誤,請檢查數量和單價是否正確填寫");

View File

@@ -23,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { STATUS_OPTIONS } from "@/constants/purchase-order";
interface Props {
orders: {
@@ -176,13 +177,11 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="processing"></SelectItem>
<SelectItem value="shipping"></SelectItem>
<SelectItem value="confirming"></SelectItem>
<SelectItem value="completed"></SelectItem>
<SelectItem value="cancelled"></SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

View File

@@ -147,20 +147,26 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
items={order.items}
isReadOnly={true}
/>
<div className="mt-4 flex flex-col items-end gap-2 border-t pt-4">
<div className="flex items-center gap-8 text-gray-600">
<span className="font-medium"></span>
<span>{formatCurrency(order.totalAmount)}</span>
</div>
<div className="flex items-center gap-8 text-gray-600">
<span className="font-medium"></span>
<span>{formatCurrency(order.tax_amount || 0)}</span>
</div>
<div className="flex items-center gap-8 pt-2 mt-2 border-t border-gray-100">
<span className="font-bold text-lg"></span>
<span className="text-xl font-bold text-primary">
{formatCurrency(order.grand_total || (order.totalAmount + (order.tax_amount || 0)))}
</span>
<div className="mt-6 flex justify-end">
<div className="w-full max-w-sm bg-primary/5 px-6 py-4 rounded-xl border border-primary/10 flex flex-col gap-3">
<div className="flex justify-between items-center w-full">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-lg font-bold text-gray-700">{formatCurrency(order.totalAmount)}</span>
</div>
<div className="flex justify-between items-center w-full">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-lg font-bold text-gray-700">{formatCurrency(order.taxAmount || 0)}</span>
</div>
<div className="h-px bg-primary/10 w-full my-1"></div>
<div className="flex justify-between items-end w-full">
<span className="text-sm text-gray-500 font-medium mb-1"> ()</span>
<span className="text-2xl font-black text-primary">
{formatCurrency(order.grandTotal || (order.totalAmount + (order.taxAmount || 0)))}
</span>
</div>
</div>
</div>
</div>

View File

@@ -130,7 +130,7 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
<CardContent className="p-6">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-500 mb-1"></span>
<span className="text-3xl font-bold text-blue-600">
<span className="text-3xl font-bold text-primary-main">
{totals.available_stock.toLocaleString()}
</span>
</div>

View File

@@ -8,7 +8,7 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createInertiaApp({
title: (title) => `${title} - ${appName}`,
title: (title) => `${title} - ${window.appName || appName}`,
resolve: (name) => resolvePageComponent(`./Pages/${name}.tsx`, import.meta.glob('./Pages/**/*.tsx')),
setup({ el, App, props }) {
const root = createRoot(el);

View File

@@ -10,12 +10,12 @@ export const STATUS_CONFIG: Record<
{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }
> = {
draft: { label: "草稿", variant: "outline" },
pending: { label: "待審核", variant: "outline" },
processing: { label: "處理中", variant: "outline" },
shipping: { label: "運送中", variant: "outline" },
confirming: { label: "待確認", variant: "outline" },
completed: { label: "已完成", variant: "outline" },
cancelled: { label: "已取消", variant: "outline" },
pending: { label: "簽核中", variant: "outline" },
approved: { label: "已核准", variant: "default" },
partial: { label: "部分收貨", variant: "secondary" },
completed: { label: "全數收貨", variant: "outline" },
closed: { label: "已結案", variant: "outline" },
cancelled: { label: "已作廢", variant: "destructive" },
};
export const STATUS_OPTIONS = Object.entries(STATUS_CONFIG).map(([value, config]) => ({

Some files were not shown because too many files have changed in this diff Show More