Compare commits
5 Commits
46753cc3bc
...
746eeb6f01
| Author | SHA1 | Date | |
|---|---|---|---|
| 746eeb6f01 | |||
| 7619dc24f7 | |||
| 2efaded77b | |||
| a31c8d6052 | |||
| 56e30a85bb |
@@ -247,6 +247,30 @@ tooltip
|
|||||||
</Can>
|
</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. 圖標規範
|
||||||
|
|||||||
158
.gemini/antigravity/skills/activity-logging/SKILL.md
Normal file
158
.gemini/antigravity/skills/activity-logging/SKILL.md
Normal 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`?
|
||||||
140
.gemini/antigravity/skills/permission-management/SKILL.md
Normal file
140
.gemini/antigravity/skills/permission-management/SKILL.md
Normal 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` 進行顯示控制。
|
||||||
990
.gemini/antigravity/skills/ui-consistency/SKILL.md
Normal file
990
.gemini/antigravity/skills/ui-consistency/SKILL.md
Normal 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 時,請務必參考此規範!
|
||||||
@@ -102,6 +102,7 @@ jobs:
|
|||||||
# 3. Laravel 初始化與優化
|
# 3. Laravel 初始化與優化
|
||||||
php artisan storage:link &&
|
php artisan storage:link &&
|
||||||
php artisan migrate --force &&
|
php artisan migrate --force &&
|
||||||
|
php artisan tenants:migrate --force &&
|
||||||
php artisan db:seed --force &&
|
php artisan db:seed --force &&
|
||||||
php artisan optimize:clear &&
|
php artisan optimize:clear &&
|
||||||
php artisan optimize &&
|
php artisan optimize &&
|
||||||
@@ -195,6 +196,7 @@ jobs:
|
|||||||
|
|
||||||
php artisan storage:link &&
|
php artisan storage:link &&
|
||||||
php artisan migrate --force &&
|
php artisan migrate --force &&
|
||||||
|
php artisan tenants:migrate --force &&
|
||||||
php artisan optimize:clear &&
|
php artisan optimize:clear &&
|
||||||
php artisan optimize &&
|
php artisan optimize &&
|
||||||
php artisan view:cache
|
php artisan view:cache
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class AdjustDocController extends Controller
|
|||||||
|
|
||||||
public function show(InventoryAdjustDoc $doc)
|
public function show(InventoryAdjustDoc $doc)
|
||||||
{
|
{
|
||||||
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse']);
|
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']);
|
||||||
|
|
||||||
$docData = [
|
$docData = [
|
||||||
'id' => (string) $doc->id,
|
'id' => (string) $doc->id,
|
||||||
@@ -141,6 +141,8 @@ class AdjustDocController extends Controller
|
|||||||
'remarks' => $doc->remarks,
|
'remarks' => $doc->remarks,
|
||||||
'created_at' => $doc->created_at->format('Y-m-d H:i'),
|
'created_at' => $doc->created_at->format('Y-m-d H:i'),
|
||||||
'created_by' => $doc->createdBy?->name,
|
'created_by' => $doc->createdBy?->name,
|
||||||
|
'count_doc_id' => $doc->count_doc_id ? (string)$doc->count_doc_id : null,
|
||||||
|
'count_doc_no' => $doc->countDoc?->doc_no,
|
||||||
'items' => $doc->items->map(function ($item) {
|
'items' => $doc->items->map(function ($item) {
|
||||||
return [
|
return [
|
||||||
'id' => (string) $item->id,
|
'id' => (string) $item->id,
|
||||||
@@ -171,7 +173,7 @@ class AdjustDocController extends Controller
|
|||||||
if ($request->input('action') === 'post') {
|
if ($request->input('action') === 'post') {
|
||||||
$this->adjustService->post($doc, auth()->id());
|
$this->adjustService->post($doc, auth()->id());
|
||||||
return redirect()->route('inventory.adjust.index')
|
return redirect()->route('inventory.adjust.index')
|
||||||
->with('success', '調整單已過帳生效');
|
->with('success', '盤調單已過帳生效');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 僅儲存資料
|
// 僅儲存資料
|
||||||
@@ -203,6 +205,6 @@ class AdjustDocController extends Controller
|
|||||||
$doc->delete();
|
$doc->delete();
|
||||||
|
|
||||||
return redirect()->route('inventory.adjust.index')
|
return redirect()->route('inventory.adjust.index')
|
||||||
->with('success', '調整單已刪除');
|
->with('success', '盤調單已刪除');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ class CountDocController extends Controller
|
|||||||
$perPage = 15;
|
$perPage = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
$docs = $query->orderByDesc('created_at')
|
$countQuery = function ($query) {
|
||||||
|
$query->whereNotNull('counted_qty');
|
||||||
|
};
|
||||||
|
|
||||||
|
$docs = $query->withCount(['items', 'items as counted_items_count' => $countQuery])
|
||||||
|
->orderByDesc('created_at')
|
||||||
->paginate($perPage)
|
->paginate($perPage)
|
||||||
->withQueryString()
|
->withQueryString()
|
||||||
->through(function ($doc) {
|
->through(function ($doc) {
|
||||||
@@ -53,6 +58,8 @@ class CountDocController extends Controller
|
|||||||
'completed_at' => $doc->completed_at ? $doc->completed_at->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,
|
'created_by' => $doc->createdBy?->name,
|
||||||
'remarks' => $doc->remarks,
|
'remarks' => $doc->remarks,
|
||||||
|
'total_items' => $doc->items_count,
|
||||||
|
'counted_items' => $doc->counted_items_count,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,6 +123,39 @@ class CountDocController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
public function update(Request $request, InventoryCountDoc $doc)
|
||||||
{
|
{
|
||||||
if ($doc->status === 'completed') {
|
if ($doc->status === 'completed') {
|
||||||
@@ -143,6 +183,23 @@ class CountDocController extends Controller
|
|||||||
return redirect()->back()->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)
|
public function destroy(InventoryCountDoc $doc)
|
||||||
{
|
{
|
||||||
if ($doc->status === 'completed') {
|
if ($doc->status === 'completed') {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class ProductController extends Controller
|
|||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%")
|
$q->where('name', 'like', "%{$search}%")
|
||||||
->orWhere('code', 'like', "%{$search}%")
|
->orWhere('code', 'like', "%{$search}%")
|
||||||
|
->orWhere('barcode', 'like', "%{$search}%")
|
||||||
->orWhere('brand', 'like', "%{$search}%");
|
->orWhere('brand', 'like', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,7 @@ class ProductController extends Controller
|
|||||||
return (object) [
|
return (object) [
|
||||||
'id' => (string) $product->id,
|
'id' => (string) $product->id,
|
||||||
'code' => $product->code,
|
'code' => $product->code,
|
||||||
|
'barcode' => $product->barcode,
|
||||||
'name' => $product->name,
|
'name' => $product->name,
|
||||||
'categoryId' => $product->category_id,
|
'categoryId' => $product->category_id,
|
||||||
'category' => $product->category ? (object) [
|
'category' => $product->category ? (object) [
|
||||||
@@ -110,6 +112,7 @@ class ProductController extends Controller
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'code' => 'required|string|max:2|unique:products,code',
|
'code' => 'required|string|max:2|unique:products,code',
|
||||||
|
'barcode' => 'required|string|unique:products,barcode',
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'category_id' => 'required|exists:categories,id',
|
'category_id' => 'required|exists:categories,id',
|
||||||
'brand' => 'nullable|string|max:255',
|
'brand' => 'nullable|string|max:255',
|
||||||
@@ -123,6 +126,8 @@ class ProductController extends Controller
|
|||||||
'code.required' => '商品代號為必填',
|
'code.required' => '商品代號為必填',
|
||||||
'code.max' => '商品代號最多 2 碼',
|
'code.max' => '商品代號最多 2 碼',
|
||||||
'code.unique' => '商品代號已存在',
|
'code.unique' => '商品代號已存在',
|
||||||
|
'barcode.required' => '條碼編號為必填',
|
||||||
|
'barcode.unique' => '條碼編號已存在',
|
||||||
'name.required' => '商品名稱為必填',
|
'name.required' => '商品名稱為必填',
|
||||||
'category_id.required' => '請選擇分類',
|
'category_id.required' => '請選擇分類',
|
||||||
'category_id.exists' => '所選分類不存在',
|
'category_id.exists' => '所選分類不存在',
|
||||||
@@ -145,6 +150,7 @@ class ProductController extends Controller
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'code' => 'required|string|max:2|unique:products,code,' . $product->id,
|
'code' => 'required|string|max:2|unique:products,code,' . $product->id,
|
||||||
|
'barcode' => 'required|string|unique:products,barcode,' . $product->id,
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'category_id' => 'required|exists:categories,id',
|
'category_id' => 'required|exists:categories,id',
|
||||||
'brand' => 'nullable|string|max:255',
|
'brand' => 'nullable|string|max:255',
|
||||||
@@ -157,6 +163,8 @@ class ProductController extends Controller
|
|||||||
'code.required' => '商品代號為必填',
|
'code.required' => '商品代號為必填',
|
||||||
'code.max' => '商品代號最多 2 碼',
|
'code.max' => '商品代號最多 2 碼',
|
||||||
'code.unique' => '商品代號已存在',
|
'code.unique' => '商品代號已存在',
|
||||||
|
'barcode.required' => '條碼編號為必填',
|
||||||
|
'barcode.unique' => '條碼編號已存在',
|
||||||
'name.required' => '商品名稱為必填',
|
'name.required' => '商品名稱為必填',
|
||||||
'category_id.required' => '請選擇分類',
|
'category_id.required' => '請選擇分類',
|
||||||
'category_id.exists' => '所選分類不存在',
|
'category_id.exists' => '所選分類不存在',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class Product extends Model
|
|||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
|
'barcode',
|
||||||
'name',
|
'name',
|
||||||
'category_id',
|
'category_id',
|
||||||
'brand',
|
'brand',
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::post('/inventory/count-docs', [CountDocController::class, 'store'])->name('inventory.count.store');
|
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::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::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
|
// 庫存盤調 (Stock Adjustment) - Global
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class AdjustService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新調整單內容 (Items)
|
* 更新盤調單內容 (Items)
|
||||||
* 此處採用 "全量更新" 方式處理 items (先刪後加),簡單可靠
|
* 此處採用 "全量更新" 方式處理 items (先刪後加),簡單可靠
|
||||||
*/
|
*/
|
||||||
public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void
|
public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void
|
||||||
@@ -123,7 +123,7 @@ class AdjustService
|
|||||||
'unit_cost' => $inventory->unit_cost,
|
'unit_cost' => $inventory->unit_cost,
|
||||||
'balance_before' => $oldQty,
|
'balance_before' => $oldQty,
|
||||||
'balance_after' => $newQty,
|
'balance_after' => $newQty,
|
||||||
'reason' => "調整單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
|
'reason' => "盤調單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
|
||||||
'actual_time' => now(),
|
'actual_time' => now(),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
]);
|
]);
|
||||||
@@ -134,6 +134,13 @@ class AdjustService
|
|||||||
'posted_at' => now(),
|
'posted_at' => now(),
|
||||||
'posted_by' => $userId,
|
'posted_by' => $userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 4. 若關聯盤點單,連動更新盤點單狀態
|
||||||
|
if ($doc->count_doc_id) {
|
||||||
|
InventoryCountDoc::where('id', $doc->count_doc_id)->update([
|
||||||
|
'status' => 'adjusted'
|
||||||
|
]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,20 +8,28 @@ use App\Modules\Production\Models\ProductionOrder;
|
|||||||
use App\Modules\Production\Models\ProductionOrderItem;
|
use App\Modules\Production\Models\ProductionOrderItem;
|
||||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||||
|
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
|
|
||||||
class ProductionOrderController extends Controller
|
class ProductionOrderController extends Controller
|
||||||
{
|
{
|
||||||
protected $inventoryService;
|
protected $inventoryService;
|
||||||
protected $coreService;
|
protected $coreService;
|
||||||
|
protected $procurementService;
|
||||||
|
|
||||||
public function __construct(InventoryServiceInterface $inventoryService, CoreServiceInterface $coreService)
|
public function __construct(
|
||||||
|
InventoryServiceInterface $inventoryService,
|
||||||
|
CoreServiceInterface $coreService,
|
||||||
|
ProcurementServiceInterface $procurementService
|
||||||
|
)
|
||||||
{
|
{
|
||||||
$this->inventoryService = $inventoryService;
|
$this->inventoryService = $inventoryService;
|
||||||
$this->coreService = $coreService;
|
$this->coreService = $coreService;
|
||||||
|
$this->procurementService = $procurementService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,9 +45,6 @@ class ProductionOrderController extends Controller
|
|||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(function ($q) use ($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}%")
|
$q->where('code', 'like', "%{$search}%")
|
||||||
->orWhere('output_batch_number', 'like', "%{$search}%");
|
->orWhere('output_batch_number', 'like', "%{$search}%");
|
||||||
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
|
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
|
||||||
@@ -205,15 +210,29 @@ class ProductionOrderController extends Controller
|
|||||||
// 手動水和明細資料
|
// 手動水和明細資料
|
||||||
$items = $productionOrder->items;
|
$items = $productionOrder->items;
|
||||||
$inventoryIds = $items->pluck('inventory_id')->unique()->filter()->toArray();
|
$inventoryIds = $items->pluck('inventory_id')->unique()->filter()->toArray();
|
||||||
|
|
||||||
|
// 修正: 移除跨模組關聯 sourcePurchaseOrder.vendor
|
||||||
$inventories = $this->inventoryService->getInventoriesByIds(
|
$inventories = $this->inventoryService->getInventoriesByIds(
|
||||||
$inventoryIds,
|
$inventoryIds,
|
||||||
['product.baseUnit', 'sourcePurchaseOrder.vendor']
|
['product.baseUnit']
|
||||||
)->keyBy('id');
|
)->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');
|
$units = $this->inventoryService->getUnits()->keyBy('id');
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$item->inventory = $inventories->get($item->inventory_id);
|
$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);
|
$item->unit = $units->get($item->unit_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,4 +188,118 @@ class RecipeController extends Controller
|
|||||||
$recipe->delete();
|
$recipe->delete();
|
||||||
return redirect()->back()->with('success', '配方已刪除');
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,5 +27,13 @@ class RecipeItem extends Model
|
|||||||
return $this->belongsTo(Recipe::class);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,10 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/api/production/warehouses/{warehouse}/inventories', [ProductionOrderController::class, 'getWarehouseInventories'])
|
Route::get('/api/production/warehouses/{warehouse}/inventories', [ProductionOrderController::class, 'getWarehouseInventories'])
|
||||||
->middleware('permission:production_orders.create')
|
->middleware('permission:production_orders.create')
|
||||||
->name('api.production.warehouses.inventories');
|
->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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
59
package-lock.json
generated
59
package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/lodash": "^4.17.21",
|
"@types/lodash": "^4.17.21",
|
||||||
@@ -75,7 +76,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@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": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
@@ -2539,7 +2585,6 @@
|
|||||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -2550,7 +2595,6 @@
|
|||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -2561,7 +2605,6 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -2669,7 +2712,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2882,8 +2924,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/date-fns": {
|
"node_modules/date-fns": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
@@ -3762,7 +3803,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3824,7 +3864,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -3837,7 +3876,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -4330,7 +4368,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/lodash": "^4.17.21",
|
"@types/lodash": "^4.17.21",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { Wand2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -36,6 +37,7 @@ export default function ProductDialog({
|
|||||||
}: ProductDialogProps) {
|
}: ProductDialogProps) {
|
||||||
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
|
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
|
||||||
code: "",
|
code: "",
|
||||||
|
barcode: "",
|
||||||
name: "",
|
name: "",
|
||||||
category_id: "",
|
category_id: "",
|
||||||
brand: "",
|
brand: "",
|
||||||
@@ -52,6 +54,7 @@ export default function ProductDialog({
|
|||||||
if (product) {
|
if (product) {
|
||||||
setData({
|
setData({
|
||||||
code: product.code,
|
code: product.code,
|
||||||
|
barcode: product.barcode || "",
|
||||||
name: product.name,
|
name: product.name,
|
||||||
category_id: product.categoryId.toString(),
|
category_id: product.categoryId.toString(),
|
||||||
brand: product.brand || "",
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
<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>}
|
{errors.code && <p className="text-sm text-red-500">{errors.code}</p>}
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="brand">品牌</Label>
|
<Label htmlFor="brand">品牌</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -74,11 +74,7 @@ export default function ProductTable({
|
|||||||
<TableHeader className="bg-gray-50">
|
<TableHeader className="bg-gray-50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
<TableHead>
|
<TableHead className="w-[150px]">條碼編號</TableHead>
|
||||||
<button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900">
|
|
||||||
商品代號 <SortIcon field="code" />
|
|
||||||
</button>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<button onClick={() => onSort("name")} className="flex items-center hover:text-gray-900">
|
<button onClick={() => onSort("name")} className="flex items-center hover:text-gray-900">
|
||||||
商品名稱 <SortIcon field="name" />
|
商品名稱 <SortIcon field="name" />
|
||||||
@@ -112,12 +108,15 @@ export default function ProductTable({
|
|||||||
{startIndex + index}
|
{startIndex + index}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-sm text-gray-700">
|
<TableCell className="font-mono text-sm text-gray-700">
|
||||||
{product.code}
|
{product.barcode || "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{product.name}</span>
|
<div className="flex items-center gap-2">
|
||||||
{product.brand && <span className="text-xs text-gray-400">{product.brand}</span>}
|
<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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
import { Head, useForm, router } from '@inertiajs/react';
|
import { Head, useForm, router, Link } from '@inertiajs/react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -15,13 +15,22 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/Components/ui/dialog";
|
} from "@/Components/ui/dialog";
|
||||||
import { Label } from "@/Components/ui/label";
|
import { Label } from "@/Components/ui/label";
|
||||||
import { Plus, Search, X, Eye, Pencil, ClipboardCheck } from "lucide-react";
|
import { Plus, Search, X, Eye, Pencil, ClipboardCheck, Trash2 } from "lucide-react";
|
||||||
import { useState, useCallback, useEffect } from '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 Pagination from '@/Components/shared/Pagination';
|
||||||
import { SearchableSelect } from '@/Components/ui/searchable-select';
|
import { SearchableSelect } from '@/Components/ui/searchable-select';
|
||||||
import { Can } from '@/Components/Permission/Can';
|
import { Can } from '@/Components/Permission/Can';
|
||||||
@@ -63,6 +72,7 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
const [searchQuery, setSearchQuery] = useState(filters.search || '');
|
const [searchQuery, setSearchQuery] = useState(filters.search || '');
|
||||||
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || '');
|
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || '');
|
||||||
const [perPage, setPerPage] = useState(filters.per_page || '15');
|
const [perPage, setPerPage] = useState(filters.per_page || '15');
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
// For Count Doc Selection
|
// For Count Doc Selection
|
||||||
const [pendingCounts, setPendingCounts] = useState<any[]>([]);
|
const [pendingCounts, setPendingCounts] = useState<any[]>([]);
|
||||||
@@ -110,10 +120,24 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
debouncedFilter({ search: searchQuery, warehouse_id: warehouseId, per_page: 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({
|
const { data, setData, post, processing, reset } = useForm({
|
||||||
count_doc_id: null as string | null,
|
count_doc_id: null as string | null,
|
||||||
warehouse_id: '',
|
warehouse_id: '',
|
||||||
reason: '',
|
reason: '手動調整庫存',
|
||||||
remarks: '',
|
remarks: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,9 +185,11 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-grey-0 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" />
|
<ClipboardCheck className="h-6 w-6 text-primary-main" />
|
||||||
庫存盤調單
|
庫存盤調管理
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">針對盤點差異進行庫存調整與過帳 (盤盈、盤虧、報廢等)</p>
|
<p className="text-gray-500 mt-1">
|
||||||
|
針對盤點差異進行庫存調整與過帳 (盤盈、盤虧、報廢等)。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -229,7 +255,7 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
<TableHead className="font-medium text-grey-600">建立者</TableHead>
|
<TableHead className="font-medium text-grey-600">建立者</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600">建立時間</TableHead>
|
<TableHead className="font-medium text-grey-600">建立時間</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600">過帳時間</TableHead>
|
<TableHead className="font-medium text-grey-600">過帳時間</TableHead>
|
||||||
<TableHead className="text-right font-medium text-grey-600">操作</TableHead>
|
<TableHead className="text-center font-medium text-grey-600">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -246,30 +272,46 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
|
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
|
||||||
onClick={() => router.visit(route('inventory.adjust.show', [doc.id]))}
|
onClick={() => router.visit(route('inventory.adjust.show', [doc.id]))}
|
||||||
>
|
>
|
||||||
<TableCell className="text-center text-grey-400 font-medium">
|
<TableCell className="text-center text-gray-500 font-medium">
|
||||||
{(docs.current_page - 1) * docs.per_page + index + 1}
|
{(docs.current_page - 1) * docs.per_page + index + 1}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-semibold text-primary-main">
|
<TableCell className="font-medium text-primary-main">
|
||||||
{doc.doc_no}
|
{doc.doc_no}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-grey-700">{doc.warehouse_name}</TableCell>
|
<TableCell>{doc.warehouse_name}</TableCell>
|
||||||
<TableCell className="text-grey-600 max-w-[200px] truncate">{doc.reason}</TableCell>
|
<TableCell className="text-gray-500 max-w-[200px] truncate">{doc.reason}</TableCell>
|
||||||
<TableCell className="text-center">{getStatusBadge(doc.status)}</TableCell>
|
<TableCell className="text-center">{getStatusBadge(doc.status)}</TableCell>
|
||||||
<TableCell className="text-grey-600 text-sm">{doc.created_by}</TableCell>
|
<TableCell className="text-sm">{doc.created_by}</TableCell>
|
||||||
<TableCell className="text-grey-400 text-xs">{doc.created_at}</TableCell>
|
<TableCell className="text-gray-500 text-sm">{doc.created_at}</TableCell>
|
||||||
<TableCell className="text-grey-400 text-xs">{doc.posted_at}</TableCell>
|
<TableCell className="text-gray-500 text-sm">{doc.posted_at || '-'}</TableCell>
|
||||||
<TableCell className="text-right">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-primary-main hover:bg-primary-50 px-2"
|
className="button-outlined-primary"
|
||||||
|
title={doc.status === 'posted' ? '查閱' : '編輯'}
|
||||||
>
|
>
|
||||||
{doc.status === 'posted' ? (
|
{doc.status === 'posted' ? (
|
||||||
<><Eye className="h-4 w-4 mr-1" /> 查閱</>
|
<Eye className="w-4 h-4" />
|
||||||
) : (
|
) : (
|
||||||
<><Pencil className="h-4 w-4 mr-1" /> 編輯</>
|
<Pencil className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@@ -300,6 +342,21 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
</div>
|
</div>
|
||||||
<Pagination links={docs.links} />
|
<Pagination links={docs.links} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Create Dialog */}
|
{/* Create Dialog */}
|
||||||
@@ -317,13 +374,16 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
|
|
||||||
<div className="py-4 space-y-6">
|
<div className="py-4 space-y-6">
|
||||||
{/* Option 1: Scan/Select from Count Docs */}
|
{/* Option 1: Scan/Select from Count Docs */}
|
||||||
<div className="space-y-4">
|
<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-semibold text-grey-700">方法一:關聯盤點單 (推薦)</Label>
|
<Label className="text-sm font-bold text-primary-main flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-4 w-4" />
|
||||||
|
方法一:關聯盤點單 (推薦)
|
||||||
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-3" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="掃描盤點單號或搜尋..."
|
placeholder="掃描盤點單號或搜尋..."
|
||||||
className="pl-9 h-11 border-primary-100 focus:ring-primary-main"
|
className="pl-9 h-9"
|
||||||
value={scanSearch}
|
value={scanSearch}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setScanSearch(e.target.value);
|
setScanSearch(e.target.value);
|
||||||
@@ -332,26 +392,26 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-height-[300px] overflow-y-auto rounded-md border border-grey-100 bg-grey-50">
|
<div className="max-h-[200px] overflow-y-auto rounded-lg border-2 border-grey-3 bg-white">
|
||||||
{loadingPending ? (
|
{loadingPending ? (
|
||||||
<div className="p-8 text-center text-sm text-grey-400">載入中...</div>
|
<div className="p-8 text-center text-sm text-grey-3">載入中...</div>
|
||||||
) : pendingCounts.length === 0 ? (
|
) : pendingCounts.length === 0 ? (
|
||||||
<div className="p-8 text-center text-sm text-grey-400">
|
<div className="p-8 text-center text-sm text-grey-3">
|
||||||
查無可供盤調的盤點單 (需為已完成狀態)
|
查無可供盤調的盤點單 (需為已核准狀態)
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-grey-100">
|
<div className="divide-y divide-grey-4">
|
||||||
{pendingCounts.map((c: any) => (
|
{pendingCounts.map((c: any) => (
|
||||||
<div
|
<div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
className="p-3 hover:bg-white flex items-center justify-between cursor-pointer group transition-colors"
|
className="p-3 hover:bg-primary-lightest flex items-center justify-between cursor-pointer group transition-colors"
|
||||||
onClick={() => handleCreate(c.id)}
|
onClick={() => handleCreate(c.id)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-grey-900 group-hover:text-primary-main">{c.doc_no}</p>
|
<p className="font-bold text-grey-0 group-hover:text-primary-main">{c.doc_no}</p>
|
||||||
<p className="text-xs text-grey-500">{c.warehouse_name} | 完成於: {c.completed_at}</p>
|
<p className="text-xs text-grey-2">{c.warehouse_name} | 完成於: {c.completed_at}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" className="button-outlined-primary">
|
<Button size="sm" variant="outline" className="button-outlined-primary h-7 text-xs">
|
||||||
選擇並載入
|
選擇並載入
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,47 +421,48 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative flex items-center py-2">
|
||||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t border-grey-200" /></div>
|
<div className="flex-grow border-t border-grey-4"></div>
|
||||||
<div className="relative flex justify-center text-xs uppercase"><span className="bg-white px-2 text-grey-400 font-medium">或</span></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>
|
</div>
|
||||||
|
|
||||||
{/* Option 2: Manual (Optional, though less common in this flow) */}
|
{/* Option 2: Manual (Optional, though less common in this flow) */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 px-1">
|
||||||
<Label className="text-sm font-semibold text-grey-700">方法二:手動建立調整</Label>
|
<Label className="text-sm font-bold text-grey-0">方法二:手動建立調整</Label>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">選擇倉庫</Label>
|
<Label className="text-xs font-semibold text-grey-1">選擇倉庫</Label>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={warehouses.map(w => ({ value: w.id, label: w.name }))}
|
options={warehouses.map(w => ({ value: w.id, label: w.name }))}
|
||||||
value={data.warehouse_id}
|
value={data.warehouse_id}
|
||||||
onValueChange={(val) => setData('warehouse_id', val)}
|
onValueChange={(val) => setData('warehouse_id', val)}
|
||||||
placeholder="選擇倉庫"
|
placeholder="選擇倉庫"
|
||||||
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">調整原因</Label>
|
<Label className="text-xs font-semibold text-grey-1">調整原因</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="例如: 報廢, 破損..."
|
placeholder="例如: 報廢, 破損..."
|
||||||
value={data.reason}
|
value={data.reason}
|
||||||
onChange={(e) => setData('reason', e.target.value)}
|
onChange={(e) => setData('reason', e.target.value)}
|
||||||
className="h-10"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
</div>
|
<Button variant="outline" className="button-outlined-primary" onClick={() => setIsDialogOpen(false)}>取消</Button>
|
||||||
|
|
||||||
<DialogFooter className="bg-gray-50 -mx-6 -mb-6 p-4 rounded-b-lg">
|
|
||||||
<Button variant="ghost" onClick={() => setIsDialogOpen(false)}>取消</Button>
|
|
||||||
<Button
|
<Button
|
||||||
className="button-filled-primary"
|
className="button-filled-primary"
|
||||||
disabled={processing || !data.warehouse_id || !data.reason}
|
disabled={processing || !data.warehouse_id || !data.reason}
|
||||||
onClick={() => handleCreate()}
|
onClick={() => handleCreate()}
|
||||||
>
|
>
|
||||||
建立手動盤調
|
新增
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
|
|||||||
@@ -11,12 +11,7 @@ import {
|
|||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { Badge } from "@/Components/ui/badge";
|
||||||
import {
|
import { Checkbox } from "@/Components/ui/checkbox";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/Components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -29,17 +24,18 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/Components/ui/alert-dialog";
|
} from "@/Components/ui/alert-dialog";
|
||||||
import { Label } from "@/Components/ui/label";
|
import { Label } from "@/Components/ui/label";
|
||||||
import { Textarea } from "@/Components/ui/textarea";
|
import { Save, CheckCircle, Trash2, ArrowLeft, Plus, ClipboardCheck, Package, Search } from "lucide-react";
|
||||||
import { Save, CheckCircle, Trash2, ArrowLeft, Plus, X, Search, FileText } from "lucide-react";
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from "@/Components/ui/dialog";
|
} from "@/Components/ui/dialog";
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Can } from '@/Components/Permission/Can';
|
import { Can } from '@/Components/Permission/Can';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface AdjItem {
|
interface AdjItem {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -48,7 +44,7 @@ interface AdjItem {
|
|||||||
product_code: string;
|
product_code: string;
|
||||||
batch_number: string | null;
|
batch_number: string | null;
|
||||||
unit: string;
|
unit: string;
|
||||||
qty_before: number;
|
qty_before: number | string;
|
||||||
adjust_qty: number | string;
|
adjust_qty: number | string;
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
@@ -79,33 +75,96 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
action: 'save',
|
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);
|
||||||
|
|
||||||
// Helper to add new item
|
useEffect(() => {
|
||||||
const addItem = (product: any, batchNumber: string | null) => {
|
if (isProductDialogOpen) {
|
||||||
// Check if exists
|
loadInventory();
|
||||||
const exists = data.items.find(i =>
|
setSelectedInventory([]); // Reset selection when opening
|
||||||
i.product_id === String(product.id) &&
|
setSearchQuery(''); // Reset search when opening
|
||||||
i.batch_number === batchNumber
|
}
|
||||||
|
}, [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) {
|
if (!exists) {
|
||||||
alert('此商品與批號已在列表中');
|
newItems.push({
|
||||||
return;
|
product_id: String(inv.product_id),
|
||||||
}
|
product_name: inv.product_name,
|
||||||
|
product_code: inv.product_code,
|
||||||
setData('items', [
|
unit: inv.unit_name,
|
||||||
...data.items,
|
batch_number: inv.batch_number,
|
||||||
{
|
qty_before: inv.quantity || 0,
|
||||||
product_id: String(product.id),
|
|
||||||
product_name: product.name,
|
|
||||||
product_code: product.code,
|
|
||||||
unit: product.unit,
|
|
||||||
batch_number: batchNumber,
|
|
||||||
qty_before: product.qty || 0, // Not fetched dynamically for now, or could fetch via API
|
|
||||||
adjust_qty: 0,
|
adjust_qty: 0,
|
||||||
notes: '',
|
notes: '',
|
||||||
|
});
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setData('items', newItems);
|
||||||
|
setIsProductDialogOpen(false);
|
||||||
|
|
||||||
|
if (addedCount > 0) {
|
||||||
|
toast.success(`已成功加入 ${addedCount} 個項目`);
|
||||||
|
} else {
|
||||||
|
toast.info("選取的商品已在清單中");
|
||||||
}
|
}
|
||||||
]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeItem = (index: number) => {
|
const removeItem = (index: number) => {
|
||||||
@@ -124,31 +183,31 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
setData('action', 'save');
|
setData('action', 'save');
|
||||||
put(route('inventory.adjust.update', [doc.id]), {
|
put(route('inventory.adjust.update', [doc.id]), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
|
onSuccess: () => toast.success("草稿儲存成功"),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePost = () => {
|
const handlePost = () => {
|
||||||
// Validate
|
|
||||||
if (data.items.length === 0) {
|
if (data.items.length === 0) {
|
||||||
alert('請至少加入一個調整項目');
|
toast.error('請至少加入一個調整項目');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasZero = data.items.some(i => Number(i.adjust_qty) === 0);
|
router.put(route('inventory.adjust.update', [doc.id]), {
|
||||||
if (hasZero && !confirm('部分項目的調整數量為 0,確定要繼續嗎?')) {
|
...data,
|
||||||
return;
|
action: 'post'
|
||||||
|
} as any, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsPostDialogOpen(false);
|
||||||
|
toast.success("盤調單過帳成功");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirm('確定要過帳嗎?過帳後將無法修改,並直接影響庫存。')) {
|
|
||||||
router.visit(route('inventory.adjust.update', [doc.id]), {
|
|
||||||
method: 'put',
|
|
||||||
data: { ...data, action: 'post' } as any,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
destroy(route('inventory.adjust.destroy', [doc.id]));
|
destroy(route('inventory.adjust.destroy', [doc.id]), {
|
||||||
|
onSuccess: () => toast.success("盤調單已刪除"),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -156,49 +215,61 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: '商品與庫存管理', href: '#' },
|
{ label: '商品與庫存管理', href: '#' },
|
||||||
{ label: '庫存盤調', href: route('inventory.adjust.index') },
|
{ label: '庫存盤調', href: route('inventory.adjust.index') },
|
||||||
{ label: doc.doc_no, href: route('inventory.adjust.show', [doc.id]), isPage: true },
|
{ label: `盤調單: ${doc.doc_no}`, href: route('inventory.adjust.show', [doc.id]), isPage: true },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Head title={`盤調單 ${doc.doc_no}`} />
|
<Head title={`盤調單 ${doc.doc_no}`} />
|
||||||
|
|
||||||
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500 space-y-6">
|
||||||
<div className="space-y-6">
|
<div>
|
||||||
|
<Link href={route('inventory.adjust.index')}>
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
className="gap-2 button-outlined-primary mb-6"
|
||||||
className="h-10 w-10 border-grey-200"
|
|
||||||
onClick={() => router.visit(route('inventory.adjust.index'))}
|
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-5 w-5 text-grey-600" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
返回盤調單列表
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
{doc.doc_no}
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
{isDraft ? (
|
<ClipboardCheck className="h-6 w-6 text-primary-main" />
|
||||||
<Badge variant="secondary" className="bg-gray-100 text-gray-600 border-none">草稿</Badge>
|
盤調單: {doc.doc_no}
|
||||||
) : (
|
|
||||||
<Badge className="bg-green-100 text-green-700 border-none">已過帳</Badge>
|
|
||||||
)}
|
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-3 mt-1 text-sm text-grey-500">
|
{isDraft ? (
|
||||||
<span className="flex items-center gap-1"><CheckCircle className="h-3 w-3" /> 倉庫: {doc.warehouse_name}</span>
|
<Badge variant="secondary" className="bg-blue-500 text-white border-none py-1 px-3">草稿</Badge>
|
||||||
<span>|</span>
|
) : (
|
||||||
<span>建立者: {doc.created_by}</span>
|
<Badge className="bg-green-500 text-white border-none py-1 px-3">已過帳</Badge>
|
||||||
<span>|</span>
|
)}
|
||||||
<span>時間: {doc.created_at}</span>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{isDraft && (
|
{isDraft && (
|
||||||
<Can permission="inventory.adjust">
|
<Can permission="inventory.adjust">
|
||||||
<AlertDialog>
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" className="text-red-500 hover:bg-red-50 hover:text-red-600">
|
<Button variant="outline" size="sm" disabled={processing} className="button-outlined-error">
|
||||||
<Trash2 className="mr-2 h-4 w-4" /> 刪除單據
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
刪除
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -210,117 +281,238 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">確認刪除</AlertDialogAction>
|
<AlertDialogAction onClick={handleDelete} className="button-filled-error">確認刪除</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<Button variant="outline" className="border-primary-200 text-primary-main hover:bg-primary-50" onClick={handleSave} disabled={processing}>
|
<Button
|
||||||
<Save className="mr-2 h-4 w-4" />
|
variant="outline"
|
||||||
儲存草稿
|
size="sm"
|
||||||
|
className="button-outlined-primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
儲存
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button className="button-filled-primary" onClick={handlePost} disabled={processing}>
|
<AlertDialog open={isPostDialogOpen} onOpenChange={setIsPostDialogOpen}>
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="button-filled-primary"
|
||||||
|
disabled={processing || data.items.length === 0}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
確認過帳
|
確認過帳
|
||||||
</Button>
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>確定要過帳嗎?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
過帳後庫存將立即根據調整數量進行增減,且無法再修改此盤調單。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handlePost} className="button-filled-primary">確認過帳</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</Can>
|
</Can>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||||
<Card className="lg:col-span-2 shadow-sm border-grey-100">
|
{/* Header Fields - Inline */}
|
||||||
<CardHeader className="bg-grey-50/50 border-b border-grey-100 py-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-2">
|
||||||
<CardTitle className="text-sm font-semibold text-grey-600">明細備註</CardTitle>
|
<div className="space-y-1">
|
||||||
</CardHeader>
|
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider font-semibold">調整原因</Label>
|
||||||
<CardContent className="pt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider">調整原因</Label>
|
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<Input
|
<Input
|
||||||
value={data.reason}
|
value={data.reason}
|
||||||
onChange={e => setData('reason', e.target.value)}
|
onChange={e => setData('reason', e.target.value)}
|
||||||
className="focus:ring-primary-main"
|
className="focus:ring-primary-main h-9"
|
||||||
|
placeholder="請輸入調整原因..."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-grey-900 font-medium">{data.reason}</div>
|
<div className="text-grey-900 font-medium py-1">{data.reason}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider">備註說明</Label>
|
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider font-semibold">備註說明</Label>
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<Textarea
|
<Input
|
||||||
value={data.remarks}
|
value={data.remarks}
|
||||||
onChange={e => setData('remarks', e.target.value)}
|
onChange={e => setData('remarks', e.target.value)}
|
||||||
rows={1}
|
className="focus:ring-primary-main h-9"
|
||||||
className="focus:ring-primary-main"
|
placeholder="選填備註..."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-grey-600">{data.remarks || '-'}</div>
|
<div className="text-grey-600 py-1">{data.remarks || '-'}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="shadow-sm border-grey-100 bg-primary-50/30">
|
<div className="border-t pt-4"></div>
|
||||||
<CardHeader className="py-3">
|
|
||||||
<CardTitle className="text-sm font-semibold text-primary-main">來源單據</CardTitle>
|
<div className="flex flex-row items-center justify-between mb-2">
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px] font-bold text-primary-main/60 uppercase">關聯盤點單</Label>
|
<h3 className="text-lg font-semibold text-grey-900">調整項目</h3>
|
||||||
{doc.count_doc_id ? (
|
<p className="text-sm text-gray-500">
|
||||||
<div className="mt-1">
|
請輸入各項商品的實盤與帳面之差異數量。正數為增加,負數為減少。
|
||||||
<Link
|
</p>
|
||||||
href={route('inventory.count.show', [doc.count_doc_id])}
|
</div>
|
||||||
className="text-primary-main font-bold hover:underline flex items-center gap-2"
|
{isDraft && !doc.count_doc_id && (
|
||||||
>
|
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
|
||||||
<FileText className="h-4 w-4" />
|
<DialogTrigger asChild>
|
||||||
{doc.count_doc_no || '檢視盤點單'}
|
<Button variant="outline" className="button-outlined-primary">
|
||||||
</Link>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-grey-400 italic text-sm mt-1">手動建立,無來源盤點單</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>
|
||||||
<div className="pt-2 border-t border-primary-100">
|
<div className="mt-6 flex items-center justify-between border-t pt-4">
|
||||||
<Label className="text-[10px] font-bold text-primary-main/60 uppercase">倉庫位置</Label>
|
<div className="flex items-center gap-3">
|
||||||
<p className="font-bold text-grey-900">{doc.warehouse_name}</p>
|
<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>
|
</div>
|
||||||
</CardContent>
|
{selectedInventory.length > 0 && (
|
||||||
</Card>
|
<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>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
|
||||||
<div className="flex flex-row items-center justify-between">
|
|
||||||
<h3 className="text-lg font-medium text-grey-900">調整項目清單</h3>
|
|
||||||
{isDraft && (
|
|
||||||
<ProductSearchDialog
|
|
||||||
warehouseId={doc.warehouse_id}
|
|
||||||
onSelect={(product, batch) => addItem(product, batch)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="bg-gray-50">
|
<TableHeader className="bg-gray-50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[60px] text-center font-medium text-grey-600">#</TableHead>
|
<TableHead className="w-[50px] text-center font-medium text-grey-600">#</TableHead>
|
||||||
<TableHead className="pl-4 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="font-medium text-grey-600">批號</TableHead>
|
||||||
<TableHead className="w-24 text-center 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 text-primary-main">調整前庫存</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="w-40 text-right font-medium text-grey-600">調整數量 (+/-)</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600">備註說明</TableHead>
|
<TableHead className="font-medium text-grey-600">備註</TableHead>
|
||||||
{isDraft && <TableHead className="w-[80px]"></TableHead>}
|
{isDraft && <TableHead className="w-[50px]"></TableHead>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={isDraft ? 8 : 7} className="h-32 text-center text-grey-400">
|
<TableCell colSpan={isDraft ? 8 : 7} className="h-32 text-center text-grey-400">
|
||||||
尚未載入任何調整項目
|
尚未加入任何項目
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@@ -330,25 +522,29 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
className="group hover:bg-gray-50/50 transition-colors"
|
className="group hover:bg-gray-50/50 transition-colors"
|
||||||
>
|
>
|
||||||
<TableCell className="text-center text-grey-400 font-medium">{index + 1}</TableCell>
|
<TableCell className="text-center text-grey-400 font-medium">{index + 1}</TableCell>
|
||||||
<TableCell className="pl-4">
|
<TableCell className="pl-4 py-3">
|
||||||
<div className="font-bold text-grey-900">{item.product_name}</div>
|
<div className="flex flex-col">
|
||||||
<div className="text-xs text-grey-500 font-mono">{item.product_code}</div>
|
<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>
|
||||||
<TableCell className="text-grey-600">{item.batch_number || '-'}</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-center text-grey-500">{item.unit}</TableCell>
|
||||||
<TableCell className="text-right font-medium text-grey-400">
|
<TableCell className="text-right font-medium text-grey-400">
|
||||||
{item.qty_before}
|
{item.qty_before}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
|
<div className="flex justify-end pr-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
className="text-right h-9 border-grey-200 focus:ring-primary-main"
|
className="text-right h-9 w-32 font-medium"
|
||||||
value={item.adjust_qty}
|
value={item.adjust_qty}
|
||||||
onChange={e => updateItem(index, 'adjust_qty', e.target.value)}
|
onChange={e => updateItem(index, 'adjust_qty', e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className={`font-bold ${Number(item.adjust_qty) > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
<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}
|
{Number(item.adjust_qty) > 0 ? '+' : ''}{item.adjust_qty}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -356,16 +552,16 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<Input
|
<Input
|
||||||
className="h-9 border-grey-200 focus:ring-primary-main"
|
className="h-9 text-sm"
|
||||||
value={item.notes || ''}
|
value={item.notes || ''}
|
||||||
onChange={e => updateItem(index, 'notes', e.target.value)}
|
onChange={e => updateItem(index, 'notes', e.target.value)}
|
||||||
placeholder="輸入備註..."
|
placeholder="備註..."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-grey-600 text-sm">{item.notes || '-'}</span>
|
<span className="text-grey-600 text-sm">{item.notes || '-'}</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{isDraft && (
|
{isDraft && !doc.count_doc_id && (
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -373,7 +569,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 p-0"
|
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 p-0"
|
||||||
onClick={() => removeItem(index)}
|
onClick={() => removeItem(index)}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
@@ -385,128 +581,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="bg-gray-50/80 border border-dashed border-grey-200 rounded-lg p-4 flex items-start gap-3">
|
|
||||||
<CheckCircle className="h-5 w-5 text-primary-main mt-0.5 shrink-0" />
|
|
||||||
<div className="text-xs text-grey-500 leading-relaxed">
|
|
||||||
<p className="font-bold text-grey-700">操作導引:</p>
|
|
||||||
<ul className="list-disc ml-4 space-y-1 mt-1">
|
|
||||||
<li><strong>調整數量 (+/-):</strong>輸入正數 (+) 表示增加庫存 (盤盈、溢收);輸入負數 (-) 表示扣減庫存 (盤虧、報廢、損耗)。</li>
|
|
||||||
<li><strong>批號控管:</strong>若商品啟用批號管理,請務必確認調整項目對應的批號是否正確。</li>
|
|
||||||
<li><strong>過帳生效:</strong>點擊「確認過帳」後,單據將轉為唯讀,並立即更新即時庫存明細與異動紀錄。</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple internal component for product search
|
|
||||||
function ProductSearchDialog({ onSelect }: { warehouseId: string, onSelect: (p: any, b: string | null) => void }) {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [results, setResults] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
// Debounce search
|
|
||||||
useEffect(() => {
|
|
||||||
if (!search) {
|
|
||||||
setResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
fetchProducts();
|
|
||||||
}, 500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
const fetchProducts = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// Using existing API logic from Goods Receipts or creating a flexible one
|
|
||||||
// Using count docs logic for now if specific endpoint not available,
|
|
||||||
// but `goods-receipts.search-products` is a good bet for general product search.
|
|
||||||
const res = await axios.get(route('goods-receipts.search-products'), { params: { query: search } });
|
|
||||||
setResults(res.data);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 border-primary-100 text-primary-main hover:bg-primary-50 px-3 flex items-center gap-2"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
新增調整項目
|
|
||||||
</Button>
|
|
||||||
<DialogContent className="sm:max-w-[500px] p-0 overflow-hidden border-none shadow-2xl">
|
|
||||||
<DialogHeader className="bg-primary-main p-6">
|
|
||||||
<DialogTitle className="text-white text-xl flex items-center gap-2">
|
|
||||||
<Search className="h-5 w-5" />
|
|
||||||
搜尋並加入商品
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-grey-400" />
|
|
||||||
<Input
|
|
||||||
placeholder="輸入商品名稱、代號或條碼..."
|
|
||||||
className="pl-11 h-12 border-grey-200 rounded-xl text-lg focus:ring-primary-main transition-all"
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-[350px] overflow-y-auto rounded-xl border border-grey-100 bg-grey-50">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-grey-400 space-y-3">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-main"></div>
|
|
||||||
<span className="text-sm font-medium">搜尋中...</span>
|
|
||||||
</div>
|
|
||||||
) : results.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-grey-400 p-8 text-center space-y-2">
|
|
||||||
<Search className="h-10 w-10 opacity-20" />
|
|
||||||
<p className="text-sm">請輸入商品關鍵字開始搜尋</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-grey-100">
|
|
||||||
{results.map(product => (
|
|
||||||
<div
|
|
||||||
key={product.id}
|
|
||||||
className="p-4 hover:bg-white cursor-pointer flex justify-between items-center group transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(product, null);
|
|
||||||
setOpen(false);
|
|
||||||
setSearch('');
|
|
||||||
setResults([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-bold text-grey-900 group-hover:text-primary-main transition-colors">{product.name}</div>
|
|
||||||
<div className="text-xs text-grey-500 font-mono tracking-tight">{product.code}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<Badge variant="outline" className="text-[10px] h-5 border-grey-200">{product.unit || '單位'}</Badge>
|
|
||||||
<span className="text-[10px] text-grey-400">點擊加入</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from '@/Components/ui/table';
|
} from '@/Components/ui/table';
|
||||||
import { Button } from '@/Components/ui/button';
|
import { Button } from '@/Components/ui/button';
|
||||||
import { Input } from '@/Components/ui/input';
|
import { Input } from '@/Components/ui/input';
|
||||||
import { Card, CardContent } from '@/Components/ui/card';
|
|
||||||
import { Badge } from '@/Components/ui/badge';
|
import { Badge } from '@/Components/ui/badge';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -31,14 +30,26 @@ import {
|
|||||||
X,
|
X,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Eye,
|
Eye,
|
||||||
Pencil
|
Pencil,
|
||||||
|
Trash2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/Components/ui/alert-dialog";
|
||||||
import Pagination from '@/Components/shared/Pagination';
|
import Pagination from '@/Components/shared/Pagination';
|
||||||
import { Can } from '@/Components/Permission/Can';
|
import { Can } from '@/Components/Permission/Can';
|
||||||
|
|
||||||
export default function Index({ auth, docs, warehouses, filters }: any) {
|
export default function Index({ auth, docs, warehouses, filters }: any) {
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const { data, setData, post, processing, reset, errors } = useForm({
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const { data, setData, post, processing, reset, errors, delete: destroy } = useForm({
|
||||||
warehouse_id: '',
|
warehouse_id: '',
|
||||||
remarks: '',
|
remarks: '',
|
||||||
});
|
});
|
||||||
@@ -108,6 +119,19 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
const getStatusBadge = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'draft':
|
case 'draft':
|
||||||
@@ -115,7 +139,9 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
|||||||
case 'counting':
|
case 'counting':
|
||||||
return <Badge className="bg-blue-500 hover:bg-blue-600">盤點中</Badge>;
|
return <Badge className="bg-blue-500 hover:bg-blue-600">盤點中</Badge>;
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return <Badge className="bg-green-500 hover:bg-green-600">已完成</Badge>;
|
return <Badge className="bg-green-500 hover:bg-green-600">已核准</Badge>;
|
||||||
|
case 'adjusted':
|
||||||
|
return <Badge className="bg-purple-500 hover:bg-purple-600">已盤調庫存</Badge>;
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
return <Badge variant="destructive">已取消</Badge>;
|
return <Badge variant="destructive">已取消</Badge>;
|
||||||
default:
|
default:
|
||||||
@@ -220,11 +246,11 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setIsCreateDialogOpen(false)}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="button-filled-primary" disabled={processing || !data.warehouse_id}>
|
<Button type="submit" className="button-filled-primary" disabled={processing || !data.warehouse_id}>
|
||||||
確認建立
|
新增
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -244,6 +270,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
|||||||
<TableHead>倉庫</TableHead>
|
<TableHead>倉庫</TableHead>
|
||||||
<TableHead>狀態</TableHead>
|
<TableHead>狀態</TableHead>
|
||||||
<TableHead>快照時間</TableHead>
|
<TableHead>快照時間</TableHead>
|
||||||
|
<TableHead>盤點進度</TableHead>
|
||||||
<TableHead>完成時間</TableHead>
|
<TableHead>完成時間</TableHead>
|
||||||
<TableHead>建立人員</TableHead>
|
<TableHead>建立人員</TableHead>
|
||||||
<TableHead className="text-center">操作</TableHead>
|
<TableHead className="text-center">操作</TableHead>
|
||||||
@@ -252,7 +279,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{docs.data.length === 0 ? (
|
{docs.data.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
|
<TableCell colSpan={9} className="text-center h-24 text-gray-500">
|
||||||
尚無盤點紀錄
|
尚無盤點紀錄
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -266,6 +293,11 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
|||||||
<TableCell>{doc.warehouse_name}</TableCell>
|
<TableCell>{doc.warehouse_name}</TableCell>
|
||||||
<TableCell>{getStatusBadge(doc.status)}</TableCell>
|
<TableCell>{getStatusBadge(doc.status)}</TableCell>
|
||||||
<TableCell className="text-gray-500 text-sm">{doc.snapshot_date}</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-gray-500 text-sm">{doc.completed_at || '-'}</TableCell>
|
||||||
<TableCell className="text-sm">{doc.created_by}</TableCell>
|
<TableCell className="text-sm">{doc.created_by}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
@@ -276,16 +308,26 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="button-outlined-primary"
|
className="button-outlined-primary"
|
||||||
title={doc.status === 'completed' ? '查閱' : '盤點'}
|
title={['completed', 'adjusted'].includes(doc.status) ? '查閱' : '盤點'}
|
||||||
>
|
>
|
||||||
{doc.status === 'completed' ? (
|
{['completed', 'adjusted'].includes(doc.status) ? (
|
||||||
<Eye className="w-4 h-4 mr-1" />
|
<Eye className="w-4 h-4 ml-0.5" />
|
||||||
) : (
|
) : (
|
||||||
<Pencil className="w-4 h-4 mr-1" />
|
<Pencil className="w-4 h-4 ml-0.5" />
|
||||||
)}
|
)}
|
||||||
{doc.status === 'completed' ? '查閱' : '盤點'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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>
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -318,6 +360,21 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
|
|||||||
</div>
|
</div>
|
||||||
<Pagination links={docs.links} />
|
<Pagination links={docs.links} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
166
resources/js/Pages/Inventory/Count/Print.tsx
Normal file
166
resources/js/Pages/Inventory/Count/Print.tsx
Normal 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>
|
||||||
|
製單人員:系統管理員 印單人員:{doc.created_by}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
第 1 頁 / 共 1 頁
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
import { Head, useForm } from '@inertiajs/react';
|
import { Head, useForm, Link, router } from '@inertiajs/react'; // Added Link
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { Button } from '@/Components/ui/button';
|
import { Button } from '@/Components/ui/button';
|
||||||
import { Input } from '@/Components/ui/input';
|
import { Input } from '@/Components/ui/input';
|
||||||
import { Badge } from '@/Components/ui/badge';
|
import { Badge } from '@/Components/ui/badge';
|
||||||
import { Save, CheckCircle, Printer, Trash2, ClipboardCheck } from 'lucide-react';
|
import { Save, CheckCircle, Printer, Trash2, ClipboardCheck, ArrowLeft, RotateCcw } from 'lucide-react'; // Added ArrowLeft
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -55,7 +55,13 @@ export default function Show({ doc }: any) {
|
|||||||
destroy(route('inventory.count.destroy', [doc.id]));
|
destroy(route('inventory.count.destroy', [doc.id]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCompleted = doc.status === 'completed';
|
const handleReopen = () => {
|
||||||
|
router.visit(route('inventory.count.reopen', [doc.id]), {
|
||||||
|
method: 'put',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCompleted = ['completed', 'adjusted'].includes(doc.status);
|
||||||
|
|
||||||
// Calculate progress
|
// Calculate progress
|
||||||
const totalItems = doc.items.length;
|
const totalItems = doc.items.length;
|
||||||
@@ -72,19 +78,32 @@ export default function Show({ doc }: any) {
|
|||||||
>
|
>
|
||||||
<Head title={`盤點單 ${doc.doc_no}`} />
|
<Head title={`盤點單 ${doc.doc_no}`} />
|
||||||
|
|
||||||
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500 space-y-6">
|
||||||
<div className="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 className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-2xl font-bold text-grey-0 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" />
|
<ClipboardCheck className="h-6 w-6 text-primary-main" />
|
||||||
盤點單: {doc.doc_no}
|
盤點單: {doc.doc_no}
|
||||||
</h1>
|
</h1>
|
||||||
{doc.status === 'completed' ? (
|
{doc.status === 'completed' && (
|
||||||
<Badge className="bg-green-500 hover:bg-green-600">已完成</Badge>
|
<Badge className="bg-green-500 hover:bg-green-600">已核准</Badge>
|
||||||
) : (
|
)}
|
||||||
|
{doc.status === 'adjusted' && (
|
||||||
|
<Badge className="bg-purple-500 hover:bg-purple-600">已盤調庫存</Badge>
|
||||||
|
)}
|
||||||
|
{doc.status === 'draft' && (
|
||||||
<Badge className="bg-blue-500 hover:bg-blue-600">盤點中</Badge>
|
<Badge className="bg-blue-500 hover:bg-blue-600">盤點中</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,8 +111,48 @@ export default function Show({ doc }: any) {
|
|||||||
倉庫: {doc.warehouse_name} <span className="mx-2">|</span> 建立人: {doc.created_by} <span className="mx-2">|</span> 快照時間: {doc.snapshot_date}
|
倉庫: {doc.warehouse_name} <span className="mx-2">|</span> 建立人: {doc.created_by} <span className="mx-2">|</span> 快照時間: {doc.snapshot_date}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{!isCompleted && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Can permission="inventory.view">
|
<Can permission="inventory.view">
|
||||||
@@ -101,7 +160,7 @@ export default function Show({ doc }: any) {
|
|||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm" disabled={processing} className="button-outlined-error">
|
<Button variant="outline" size="sm" disabled={processing} className="button-outlined-error">
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
作廢盤點單
|
作廢
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -126,7 +185,7 @@ export default function Show({ doc }: any) {
|
|||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4 mr-2" />
|
<Save className="w-4 h-4 mr-2" />
|
||||||
暫存進度
|
更新
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -135,17 +194,12 @@ export default function Show({ doc }: any) {
|
|||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
完成盤點
|
完成
|
||||||
</Button>
|
</Button>
|
||||||
</Can>
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isCompleted && (
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => window.print()}>
|
|
||||||
<Printer className="w-4 h-4 mr-2" />
|
|
||||||
列印報表
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -205,14 +259,14 @@ export default function Show({ doc }: any) {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
|
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
|
||||||
<TableCell className="text-right font-medium">{item.system_qty.toFixed(2)}</TableCell>
|
<TableCell className="text-right font-medium">{item.system_qty.toFixed(0)}</TableCell>
|
||||||
<TableCell className="text-right px-1 py-3">
|
<TableCell className="text-right px-1 py-3">
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<span className="font-semibold mr-2">{item.counted_qty}</span>
|
<span className="font-semibold mr-2">{item.counted_qty}</span>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="1"
|
||||||
value={formItem.counted_qty ?? ''}
|
value={formItem.counted_qty ?? ''}
|
||||||
onChange={(e) => updateItem(index, 'counted_qty', e.target.value)}
|
onChange={(e) => updateItem(index, 'counted_qty', e.target.value)}
|
||||||
onWheel={(e: any) => e.target.blur()}
|
onWheel={(e: any) => e.target.blur()}
|
||||||
@@ -230,7 +284,7 @@ export default function Show({ doc }: any) {
|
|||||||
: 'text-red-600'
|
: 'text-red-600'
|
||||||
}`}>
|
}`}>
|
||||||
{formItem.counted_qty !== '' && formItem.counted_qty !== null
|
{formItem.counted_qty !== '' && formItem.counted_qty !== null
|
||||||
? diff.toFixed(2)
|
? diff.toFixed(0)
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -257,20 +311,9 @@ export default function Show({ doc }: any) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="bg-gray-50/80 border border-dashed border-grey-200 rounded-lg p-4 flex items-start gap-3">
|
|
||||||
<div className="bg-blue-100 p-2 rounded-lg">
|
|
||||||
<Save className="w-5 h-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-1 text-sm">操作導引</h4>
|
|
||||||
<p className="text-xs text-gray-500 leading-relaxed">
|
|
||||||
資料會自動保存盤點人與時間。若尚未盤點完,您可以點擊「暫存進度」稍後繼續。
|
|
||||||
確認所有項目資料正確後,請點擊「完成盤點」結束盤點作業。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</AuthenticatedLayout >
|
</AuthenticatedLayout >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, Link, router } from "@inertiajs/react";
|
import { Head, Link, router } from "@inertiajs/react";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -20,41 +22,94 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
} from "@/Components/ui/dialog";
|
} from "@/Components/ui/dialog";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/Components/ui/select";
|
|
||||||
import { Label } from "@/Components/ui/label";
|
import { Label } from "@/Components/ui/label";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
FileText,
|
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Eye,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/Components/ui/alert-dialog";
|
||||||
import Pagination from "@/Components/shared/Pagination";
|
import Pagination from "@/Components/shared/Pagination";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Can } from '@/Components/Permission/Can';
|
||||||
|
|
||||||
export default function Index({ auth, orders, warehouses, filters }) {
|
export default function Index({ warehouses, orders, filters }: any) {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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
|
// Create Dialog State
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [sourceWarehouseId, setSourceWarehouseId] = useState("");
|
const [sourceWarehouseId, setSourceWarehouseId] = useState("");
|
||||||
const [targetWarehouseId, setTargetWarehouseId] = useState("");
|
const [targetWarehouseId, setTargetWarehouseId] = useState("");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Handle warehouse filter change
|
// Debounced Search Handler
|
||||||
const handleFilterChange = (value) => {
|
const debouncedSearch = useCallback(
|
||||||
router.get(route('inventory.transfer.index'), { warehouse_id: value }, {
|
debounce((term: string, warehouse: string) => {
|
||||||
preserveState: true,
|
router.get(
|
||||||
replace: true,
|
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 = () => {
|
const handleCreate = () => {
|
||||||
@@ -84,12 +139,28 @@ export default function Index({ auth, orders, warehouses, filters }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status) => {
|
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) {
|
switch (status) {
|
||||||
case 'draft':
|
case 'draft':
|
||||||
return <Badge variant="secondary">草稿</Badge>;
|
return <Badge variant="secondary">草稿</Badge>;
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return <Badge className="bg-green-600">已完成</Badge>;
|
return <Badge className="bg-green-500 hover:bg-green-600">已完成</Badge>;
|
||||||
case 'voided':
|
case 'voided':
|
||||||
return <Badge variant="destructive">已作廢</Badge>;
|
return <Badge variant="destructive">已作廢</Badge>;
|
||||||
default:
|
default:
|
||||||
@@ -99,142 +170,184 @@ export default function Index({ auth, orders, warehouses, filters }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout
|
<AuthenticatedLayout
|
||||||
user={auth.user}
|
breadcrumbs={[
|
||||||
header={
|
{ label: '商品與庫存與管理', href: '#' },
|
||||||
<div className="flex justify-between items-center">
|
{ label: '庫存調撥', href: route('inventory.transfer.index'), isPage: true },
|
||||||
<h2 className="font-semibold text-xl text-gray-800 leading-tight flex items-center gap-2">
|
]}
|
||||||
<ArrowLeftRight className="h-5 w-5" />
|
|
||||||
庫存調撥
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Head title="庫存調撥" />
|
<Head title="庫存調撥" />
|
||||||
|
|
||||||
<div className="py-12">
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
<div>
|
||||||
<div className="p-6">
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<ArrowLeftRight className="h-6 w-6 text-primary-main" />
|
||||||
<div className="flex items-center gap-4">
|
庫存調撥管理
|
||||||
<div className="w-[200px]">
|
</h1>
|
||||||
<Select
|
<p className="text-gray-500 mt-1">
|
||||||
value={filters.warehouse_id || "all"}
|
建立與管理倉庫間的商品調撥單,追蹤庫存轉移紀錄。
|
||||||
onValueChange={(val) => handleFilterChange(val === "all" ? "" : val)}
|
</p>
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="篩選倉庫..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">所有倉庫</SelectItem>
|
|
||||||
{warehouses.map((w) => (
|
|
||||||
<SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="relative flex-1 max-w-sm">
|
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
placeholder="搜尋調撥單號..."
|
|
||||||
className="pl-9"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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}>
|
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
<Button className="flex-1 md:flex-none button-filled-primary">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
新增調撥單
|
新增調撥單
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>建立新調撥單</DialogTitle>
|
<DialogTitle>建立新調撥單</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
請選擇來源倉庫與目的倉庫。
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>來源倉庫</Label>
|
<Label>來源倉庫</Label>
|
||||||
<Select onValueChange={setSourceWarehouseId} value={sourceWarehouseId}>
|
<SearchableSelect
|
||||||
<SelectTrigger>
|
value={sourceWarehouseId}
|
||||||
<SelectValue placeholder="選擇來源倉庫" />
|
onValueChange={setSourceWarehouseId}
|
||||||
</SelectTrigger>
|
options={warehouses.map((w: any) => ({ label: w.name, value: w.id.toString() }))}
|
||||||
<SelectContent>
|
placeholder="請選擇來源倉庫"
|
||||||
{warehouses.map(w => (
|
className="h-9"
|
||||||
<SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>
|
/>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>目的倉庫</Label>
|
<Label>目的倉庫</Label>
|
||||||
<Select onValueChange={setTargetWarehouseId} value={targetWarehouseId}>
|
<SearchableSelect
|
||||||
<SelectTrigger>
|
value={targetWarehouseId}
|
||||||
<SelectValue placeholder="選擇目的倉庫" />
|
onValueChange={setTargetWarehouseId}
|
||||||
</SelectTrigger>
|
options={warehouses.filter((w: any) => w.id.toString() !== sourceWarehouseId).map((w: any) => ({ label: w.name, value: w.id.toString() }))}
|
||||||
<SelectContent>
|
placeholder="請選擇目的倉庫"
|
||||||
{warehouses.filter(w => String(w.id) !== sourceWarehouseId).map(w => (
|
className="h-9"
|
||||||
<SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>
|
/>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)}>取消</Button>
|
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setIsCreateOpen(false)}>
|
||||||
<Button onClick={handleCreate} disabled={creating || !sourceWarehouseId || !targetWarehouseId}>
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate} className="button-filled-primary" disabled={creating || !sourceWarehouseId || !targetWarehouseId}>
|
||||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
建立草稿
|
新增
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className="bg-gray-50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>單號</TableHead>
|
<TableHead className="w-[50px] text-center font-medium text-gray-600">#</TableHead>
|
||||||
<TableHead>狀態</TableHead>
|
<TableHead className="font-medium text-gray-600">單號</TableHead>
|
||||||
<TableHead>來源倉庫</TableHead>
|
<TableHead className="text-center font-medium text-gray-600">狀態</TableHead>
|
||||||
<TableHead>目的倉庫</TableHead>
|
<TableHead className="font-medium text-gray-600">來源倉庫</TableHead>
|
||||||
<TableHead>建立日期</TableHead>
|
<TableHead className="font-medium text-gray-600">目的倉庫</TableHead>
|
||||||
<TableHead>過帳日期</TableHead>
|
<TableHead className="font-medium text-gray-600">建立日期</TableHead>
|
||||||
<TableHead>建立人員</TableHead>
|
<TableHead className="font-medium text-gray-600">過帳日期</TableHead>
|
||||||
<TableHead className="text-right">操作</TableHead>
|
<TableHead className="font-medium text-gray-600">建立人員</TableHead>
|
||||||
|
<TableHead className="text-center font-medium text-gray-600">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{orders.data.length === 0 ? (
|
{orders.data.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
|
<TableCell colSpan={9} className="text-center h-24 text-gray-500">
|
||||||
尚無調撥單
|
尚無調撥紀錄
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
orders.data.map((order) => (
|
orders.data.map((order: any, index: number) => (
|
||||||
<TableRow key={order.id}>
|
<TableRow
|
||||||
<TableCell className="font-medium">{order.doc_no}</TableCell>
|
key={order.id}
|
||||||
<TableCell>{getStatusBadge(order.status)}</TableCell>
|
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
|
||||||
<TableCell>{order.from_warehouse_name}</TableCell>
|
onClick={() => router.visit(route('inventory.transfer.show', [order.id]))}
|
||||||
<TableCell>{order.to_warehouse_name}</TableCell>
|
|
||||||
<TableCell>{order.created_at}</TableCell>
|
|
||||||
<TableCell>{order.posted_at}</TableCell>
|
|
||||||
<TableCell>{order.created_by}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
asChild
|
|
||||||
>
|
>
|
||||||
|
<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])}>
|
<Link href={route('inventory.transfer.show', [order.id])}>
|
||||||
查看
|
<Button
|
||||||
</Link>
|
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>
|
</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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@@ -243,12 +356,43 @@ export default function Index({ auth, orders, warehouses, filters }) {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<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} />
|
<Pagination links={orders.links} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, router, usePage } from "@inertiajs/react";
|
import { Head, router, Link } from "@inertiajs/react";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import { Label } from "@/Components/ui/label";
|
import { Label } from "@/Components/ui/label";
|
||||||
import { Textarea } from "@/Components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -15,38 +14,49 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/Components/ui/table";
|
} from "@/Components/ui/table";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
import { Checkbox } from "@/Components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
DialogFooter,
|
|
||||||
} from "@/Components/ui/dialog";
|
} from "@/Components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Select,
|
AlertDialog,
|
||||||
SelectContent,
|
AlertDialogAction,
|
||||||
SelectItem,
|
AlertDialogCancel,
|
||||||
SelectTrigger,
|
AlertDialogContent,
|
||||||
SelectValue,
|
AlertDialogDescription,
|
||||||
} from "@/Components/ui/select";
|
AlertDialogFooter,
|
||||||
import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Ban, History, Package } from "lucide-react";
|
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 { toast } from "sonner";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { Can } from '@/Components/Permission/Can';
|
||||||
|
|
||||||
export default function Show({ auth, order }) {
|
export default function Show({ order }: any) {
|
||||||
const [items, setItems] = useState(order.items || []);
|
const [items, setItems] = useState(order.items || []);
|
||||||
const [remarks, setRemarks] = useState(order.remarks || "");
|
const [remarks, setRemarks] = useState(order.remarks || "");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Product Selection
|
// Product Selection
|
||||||
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false);
|
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false);
|
||||||
const [availableInventory, setAvailableInventory] = useState([]);
|
const [availableInventory, setAvailableInventory] = useState<any[]>([]);
|
||||||
const [loadingInventory, setLoadingInventory] = useState(false);
|
const [loadingInventory, setLoadingInventory] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedInventory, setSelectedInventory] = useState<string[]>([]); // product_id-batch
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isProductDialogOpen) {
|
if (isProductDialogOpen) {
|
||||||
loadInventory();
|
loadInventory();
|
||||||
|
setSelectedInventory([]);
|
||||||
|
setSearchQuery('');
|
||||||
}
|
}
|
||||||
}, [isProductDialogOpen]);
|
}, [isProductDialogOpen]);
|
||||||
|
|
||||||
@@ -64,39 +74,75 @@ export default function Show({ auth, order }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddItem = (inventoryItem) => {
|
const toggleSelect = (key: string) => {
|
||||||
// Check if already added
|
setSelectedInventory(prev =>
|
||||||
const exists = items.find(i =>
|
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||||
i.product_id === inventoryItem.product_id &&
|
|
||||||
i.batch_number === inventoryItem.batch_number
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
toast.error("該商品與批號已在列表中");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setItems([...items, {
|
|
||||||
product_id: inventoryItem.product_id,
|
|
||||||
product_name: inventoryItem.product_name,
|
|
||||||
product_code: inventoryItem.product_code,
|
|
||||||
batch_number: inventoryItem.batch_number,
|
|
||||||
unit: inventoryItem.unit_name,
|
|
||||||
quantity: 1, // Default 1
|
|
||||||
max_quantity: inventoryItem.quantity, // Max available
|
|
||||||
notes: "",
|
|
||||||
}]);
|
|
||||||
setIsProductDialogOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateItem = (index, field, value) => {
|
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];
|
const newItems = [...items];
|
||||||
newItems[index][field] = value;
|
newItems[index][field] = value;
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveItem = (index) => {
|
const handleRemoveItem = (index: number) => {
|
||||||
const newItems = items.filter((_, i) => i !== index);
|
const newItems = items.filter((_: any, i: number) => i !== index);
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,208 +162,370 @@ export default function Show({ auth, order }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePost = () => {
|
const handlePost = () => {
|
||||||
if (!confirm("確定要過帳嗎?過帳後庫存將立即轉移且無法修改。")) return;
|
|
||||||
router.put(route('inventory.transfer.update', [order.id]), {
|
router.put(route('inventory.transfer.update', [order.id]), {
|
||||||
action: 'post'
|
action: 'post'
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsPostDialogOpen(false);
|
||||||
|
toast.success("過帳成功");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (!confirm("確定要刪除此草稿嗎?")) return;
|
router.delete(route('inventory.transfer.destroy', [order.id]), {
|
||||||
router.delete(route('inventory.transfer.destroy', [order.id]));
|
onSuccess: () => {
|
||||||
|
setDeleteId(null);
|
||||||
|
toast.success("已成功刪除");
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isReadOnly = order.status !== 'draft';
|
const isReadOnly = order.status !== 'draft';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout
|
<AuthenticatedLayout
|
||||||
user={auth.user}
|
|
||||||
header={
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h2 className="font-semibold text-xl text-gray-800 leading-tight flex items-center gap-2">
|
|
||||||
<ArrowLeft className="h-5 w-5 cursor-pointer" onClick={() => router.visit(route('inventory.transfer.index'))} />
|
|
||||||
調撥單詳情 ({order.doc_no})
|
|
||||||
</h2>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{!isReadOnly && (
|
|
||||||
<>
|
|
||||||
<Button variant="destructive" onClick={handleDelete}>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
刪除
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={handleSave} disabled={isSaving}>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
儲存草稿
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handlePost} disabled={items.length === 0}>
|
|
||||||
<CheckCircle className="h-4 w-4 mr-2" />
|
|
||||||
確認過帳
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: '首頁', href: '/' },
|
{ label: '商品與庫存管理', href: '#' },
|
||||||
{ label: '庫存調撥', href: route('inventory.transfer.index') },
|
{ label: '庫存調撥', href: route('inventory.transfer.index') },
|
||||||
{ label: order.doc_no, href: route('inventory.transfer.show', [order.id]) },
|
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Head title={`調撥單 ${order.doc_no}`} />
|
<Head title={`調撥單 ${order.doc_no}`} />
|
||||||
|
|
||||||
<div className="py-12">
|
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500 space-y-6">
|
||||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
|
||||||
{/* Header Info */}
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100 grid grid-cols-1 md:grid-cols-4 gap-6">
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-gray-500">來源倉庫</Label>
|
<Link href={route('inventory.transfer.index')}>
|
||||||
<div className="font-medium text-lg">{order.from_warehouse_name}</div>
|
<Button
|
||||||
</div>
|
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>
|
||||||
<Label className="text-gray-500">目的倉庫</Label>
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-medium text-lg">{order.to_warehouse_name}</div>
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
</div>
|
<ArrowLeftRight className="h-6 w-6 text-primary-main" />
|
||||||
<div>
|
調撥單: {order.doc_no}
|
||||||
<Label className="text-gray-500">狀態</Label>
|
</h1>
|
||||||
<div className="mt-1">
|
{order.status === 'completed' && <Badge className="bg-green-500 hover:bg-green-600">已完成</Badge>}
|
||||||
{order.status === 'draft' && <Badge variant="secondary">草稿</Badge>}
|
{order.status === 'draft' && <Badge className="bg-blue-500 hover:bg-blue-600">草稿</Badge>}
|
||||||
{order.status === 'completed' && <Badge className="bg-green-600">已完成</Badge>}
|
|
||||||
{order.status === 'voided' && <Badge variant="destructive">已作廢</Badge>}
|
{order.status === 'voided' && <Badge variant="destructive">已作廢</Badge>}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div>
|
|
||||||
<Label className="text-gray-500">備註</Label>
|
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
<div className="mt-1 text-gray-700">{order.remarks || '-'}</div>
|
<div className="text-gray-700 p-2 bg-gray-50 rounded border text-sm min-h-[40px]">
|
||||||
|
{order.remarks || '無備註'}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
value={remarks}
|
value={remarks}
|
||||||
onChange={(e) => setRemarks(e.target.value)}
|
onChange={(e) => setRemarks(e.target.value)}
|
||||||
className="mt-1"
|
className="h-9 focus:ring-primary-main"
|
||||||
placeholder="填寫備註..."
|
placeholder="填寫調撥單備註..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Items */}
|
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||||
<div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
<div className="flex items-center justify-between">
|
||||||
<div className="p-6">
|
<div>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<h3 className="font-semibold text-lg text-grey-900">調撥明細</h3>
|
||||||
<h3 className="text-lg font-semibold">調撥明細</h3>
|
<p className="text-sm text-grey-500">
|
||||||
|
請選擇要調撥的商品並輸入數量。所有商品將從「{order.from_warehouse_name}」轉出。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
|
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline">
|
<Button variant="outline" className="button-outlined-primary">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
加入商品
|
加入商品
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col p-6">
|
||||||
<DialogHeader>
|
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||||
<DialogTitle>選擇來源庫存 ({order.from_warehouse_name})</DialogTitle>
|
<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>
|
</DialogHeader>
|
||||||
<div className="mt-4">
|
<div className="flex-1 overflow-auto pr-1">
|
||||||
{loadingInventory ? (
|
{loadingInventory ? (
|
||||||
<div className="text-center py-4">載入中...</div>
|
<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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className="bg-gray-50/80 sticky top-0 z-10 shadow-sm">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>商品代號</TableHead>
|
<TableHead className="w-[50px] text-center">
|
||||||
<TableHead>品名</TableHead>
|
<Checkbox
|
||||||
<TableHead>批號</TableHead>
|
checked={availableInventory.length > 0 && (() => {
|
||||||
<TableHead className="text-right">現有庫存</TableHead>
|
const filtered = availableInventory.filter(inv =>
|
||||||
<TableHead></TableHead>
|
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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{availableInventory.map((inv) => (
|
{(() => {
|
||||||
<TableRow key={`${inv.product_id}-${inv.batch_number}`}>
|
const filtered = availableInventory.filter(inv =>
|
||||||
<TableCell>{inv.product_code}</TableCell>
|
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
<TableCell>{inv.product_name}</TableCell>
|
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
<TableCell>{inv.batch_number || '-'}</TableCell>
|
);
|
||||||
<TableCell className="text-right">{inv.quantity} {inv.unit_name}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
if (filtered.length === 0) {
|
||||||
<Button size="sm" onClick={() => handleAddItem(inv)}>
|
return (
|
||||||
選取
|
<TableRow>
|
||||||
</Button>
|
<TableCell colSpan={5} className="text-center py-12 text-grey-3 italic font-medium">
|
||||||
|
{searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className="bg-gray-50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[50px]">#</TableHead>
|
<TableHead className="w-[50px] text-center font-medium text-grey-600">#</TableHead>
|
||||||
<TableHead>商品</TableHead>
|
<TableHead className="font-medium text-grey-600">商品名稱 / 代號</TableHead>
|
||||||
<TableHead>批號</TableHead>
|
<TableHead className="font-medium text-grey-600">批號</TableHead>
|
||||||
<TableHead className="w-[150px]">調撥數量</TableHead>
|
<TableHead className="text-right w-32 font-medium text-grey-600">可用庫存</TableHead>
|
||||||
<TableHead>單位</TableHead>
|
<TableHead className="text-right w-40 font-medium text-grey-600">調撥數量</TableHead>
|
||||||
<TableHead>備註</TableHead>
|
<TableHead className="font-medium text-grey-600">單位</TableHead>
|
||||||
|
<TableHead className="font-medium text-grey-600">備註</TableHead>
|
||||||
{!isReadOnly && <TableHead className="w-[50px]"></TableHead>}
|
{!isReadOnly && <TableHead className="w-[50px]"></TableHead>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center h-24 text-gray-500">
|
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
|
||||||
尚未加入商品
|
尚未加入商品
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
items.map((item, index) => (
|
items.map((item: any, index: number) => (
|
||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
<TableCell>{index + 1}</TableCell>
|
<TableCell className="text-center text-gray-500 font-medium">{index + 1}</TableCell>
|
||||||
<TableCell>
|
<TableCell className="py-3">
|
||||||
<div>{item.product_name}</div>
|
<div className="flex flex-col">
|
||||||
<div className="text-xs text-gray-500">{item.product_code}</div>
|
<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>
|
||||||
<TableCell>{item.batch_number || '-'}</TableCell>
|
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
|
||||||
<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 ? (
|
{isReadOnly ? (
|
||||||
item.quantity
|
<div className="text-right font-semibold mr-2">{item.quantity}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1 items-end pr-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.01"
|
min="0.01"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)}
|
onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)}
|
||||||
|
className="h-9 w-32 text-right font-medium focus:ring-primary-main"
|
||||||
/>
|
/>
|
||||||
{item.max_quantity && (
|
|
||||||
<span className="text-xs text-gray-500">上限: {item.max_quantity}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{item.unit}</TableCell>
|
<TableCell className="text-sm text-gray-500">{item.unit || item.unit_name}</TableCell>
|
||||||
<TableCell>
|
<TableCell className="px-1">
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
item.notes
|
<span className="text-sm text-gray-600">{item.notes}</span>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
value={item.notes}
|
value={item.notes}
|
||||||
onChange={(e) => handleUpdateItem(index, 'notes', e.target.value)}
|
onChange={(e) => handleUpdateItem(index, 'notes', e.target.value)}
|
||||||
placeholder="備註"
|
placeholder="備註..."
|
||||||
|
className="h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<TableCell>
|
<TableCell className="text-center">
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleRemoveItem(index)}>
|
<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 text-red-500" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
@@ -329,8 +537,6 @@ export default function Show({ auth, order }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface Category {
|
|||||||
export interface Product {
|
export interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
barcode?: string;
|
||||||
name: string;
|
name: string;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
category?: Category;
|
category?: Category;
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
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 { Button } from "@/Components/ui/button";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, router, useForm } from "@inertiajs/react";
|
import { Head, router, useForm } from "@inertiajs/react";
|
||||||
import toast, { Toaster } from 'react-hot-toast';
|
import { toast } from "sonner";
|
||||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
@@ -90,12 +90,17 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
|
|
||||||
const [bomItems, setBomItems] = useState<BomItem[]>([]);
|
const [bomItems, setBomItems] = useState<BomItem[]>([]);
|
||||||
|
|
||||||
|
// 多配方支援
|
||||||
|
const [recipes, setRecipes] = useState<any[]>([]);
|
||||||
|
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
|
||||||
|
|
||||||
const { data, setData, processing, errors } = useForm({
|
const { data, setData, processing, errors } = useForm({
|
||||||
product_id: "",
|
product_id: "",
|
||||||
warehouse_id: "",
|
warehouse_id: "",
|
||||||
output_quantity: "",
|
output_quantity: "",
|
||||||
output_batch_number: "",
|
output_batch_number: "",
|
||||||
output_box_count: "",
|
// 移除 Box Count UI
|
||||||
|
// 移除相關邏輯
|
||||||
production_date: new Date().toISOString().split('T')[0],
|
production_date: new Date().toISOString().split('T')[0],
|
||||||
expiry_date: "",
|
expiry_date: "",
|
||||||
remark: "",
|
remark: "",
|
||||||
@@ -244,19 +249,66 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
})));
|
})));
|
||||||
}, [bomItems]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!data.product_id) return;
|
if (!data.product_id) return;
|
||||||
|
|
||||||
|
// 1. 自動產生成品批號
|
||||||
const product = products.find(p => String(p.id) === data.product_id);
|
const product = products.find(p => String(p.id) === data.product_id);
|
||||||
if (!product) return;
|
if (product) {
|
||||||
|
const datePart = data.production_date;
|
||||||
const datePart = data.production_date; // YYYY-MM-DD
|
|
||||||
const dateFormatted = datePart.replace(/-/g, '');
|
const dateFormatted = datePart.replace(/-/g, '');
|
||||||
const originCountry = 'TW';
|
const originCountry = 'TW';
|
||||||
|
|
||||||
// 呼叫 API 取得下一組流水號
|
|
||||||
// 複用庫存批號 API,但這裡可能沒有選 warehouse,所以用第一個預設
|
|
||||||
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
|
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
|
||||||
|
|
||||||
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
|
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
|
||||||
@@ -267,11 +319,46 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
setData('output_batch_number', suggested);
|
setData('output_batch_number', suggested);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Fallback:若 API 失敗,使用預設 01
|
|
||||||
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
|
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
|
||||||
setData('output_batch_number', suggested);
|
setData('output_batch_number', suggested);
|
||||||
});
|
});
|
||||||
}, [data.product_id, data.production_date]);
|
}
|
||||||
|
|
||||||
|
// 2. 自動載入配方列表
|
||||||
|
const fetchRecipes = async () => {
|
||||||
|
try {
|
||||||
|
// 改為抓取所有配方
|
||||||
|
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
|
||||||
|
const recipesData = await res.json();
|
||||||
|
|
||||||
|
if (Array.isArray(recipesData) && recipesData.length > 0) {
|
||||||
|
setRecipes(recipesData);
|
||||||
|
// 預設選取最新的 (第一個)
|
||||||
|
const latest = recipesData[0];
|
||||||
|
setSelectedRecipeId(String(latest.id));
|
||||||
|
} 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') => {
|
const submit = (status: 'draft' | 'completed') => {
|
||||||
@@ -286,12 +373,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
if (bomItems.length === 0) missingFields.push('原物料明細');
|
if (bomItems.length === 0) missingFields.push('原物料明細');
|
||||||
|
|
||||||
if (missingFields.length > 0) {
|
if (missingFields.length > 0) {
|
||||||
toast.error(
|
toast.error("請填寫必要欄位", {
|
||||||
<div className="flex flex-col gap-1">
|
description: `缺漏:${missingFields.join('、')}`
|
||||||
<span className="font-bold">請填寫必要欄位</span>
|
});
|
||||||
<span className="text-sm">缺漏:{missingFields.join('、')}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,12 +397,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
}, {
|
}, {
|
||||||
onError: (errors) => {
|
onError: (errors) => {
|
||||||
const errorCount = Object.keys(errors).length;
|
const errorCount = Object.keys(errors).length;
|
||||||
toast.error(
|
toast.error("建立失敗,請檢查表單", {
|
||||||
<div className="flex flex-col gap-1">
|
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
|
||||||
<span className="font-bold">建立失敗,請檢查表單</span>
|
});
|
||||||
<span className="text-sm">共有 {errorCount} 個欄位有誤,請修正後再試</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -331,7 +412,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
return (
|
return (
|
||||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
|
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
|
||||||
<Head title="建立生產單" />
|
<Head title="建立生產單" />
|
||||||
<Toaster position="top-right" />
|
|
||||||
<div className="container mx-auto p-6 max-w-7xl">
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link href={route('production-orders.index')}>
|
<Link href={route('production-orders.index')}>
|
||||||
@@ -394,6 +475,28 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
className="w-full h-9"
|
className="w-full h-9"
|
||||||
/>
|
/>
|
||||||
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
|
{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>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<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>}
|
{errors.output_batch_number && <p className="text-red-500 text-xs mt-1">{errors.output_batch_number}</p>}
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium text-grey-2">生產日期 *</Label>
|
<Label className="text-xs font-medium text-grey-2">生產日期 *</Label>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
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 { Button } from "@/Components/ui/button";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, router, Link } from "@inertiajs/react";
|
import { Head, router, Link } from "@inertiajs/react";
|
||||||
@@ -15,6 +15,8 @@ import { Label } from "@/Components/ui/label";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { Badge } from "@/Components/ui/badge";
|
||||||
import { Can } from "@/Components/Permission/Can";
|
import { Can } from "@/Components/Permission/Can";
|
||||||
|
import { RecipeDetailModal } from "./Components/RecipeDetailModal";
|
||||||
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -59,6 +61,11 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
|||||||
const [search, setSearch] = useState(filters.search || "");
|
const [search, setSearch] = useState(filters.search || "");
|
||||||
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
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(() => {
|
useEffect(() => {
|
||||||
setSearch(filters.search || "");
|
setSearch(filters.search || "");
|
||||||
setPerPage(filters.per_page || "10");
|
setPerPage(filters.per_page || "10");
|
||||||
@@ -95,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 (
|
return (
|
||||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes")}>
|
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes")}>
|
||||||
<Head title="配方管理" />
|
<Head title="配方管理" />
|
||||||
@@ -171,7 +192,7 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
|||||||
<TableHead className="text-right">標準產量</TableHead>
|
<TableHead className="text-right">標準產量</TableHead>
|
||||||
<TableHead className="text-center w-[100px]">狀態</TableHead>
|
<TableHead className="text-center w-[100px]">狀態</TableHead>
|
||||||
<TableHead className="w-[150px]">更新時間</TableHead>
|
<TableHead className="w-[150px]">更新時間</TableHead>
|
||||||
<TableHead className="text-center w-[120px]">操作</TableHead>
|
<TableHead className="text-center w-[150px]">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -221,6 +242,17 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Can permission="recipes.view">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary"
|
||||||
|
title="查看明細"
|
||||||
|
onClick={() => handleView(recipe.id)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
<Can permission="recipes.edit">
|
<Can permission="recipes.edit">
|
||||||
<Link href={route('recipes.edit', recipe.id)}>
|
<Link href={route('recipes.edit', recipe.id)}>
|
||||||
<Button
|
<Button
|
||||||
@@ -296,6 +328,13 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
|||||||
<Pagination links={recipes.links} />
|
<Pagination links={recipes.links} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RecipeDetailModal
|
||||||
|
isOpen={isViewModalOpen}
|
||||||
|
onClose={() => setIsViewModalOpen(false)}
|
||||||
|
recipe={viewRecipe}
|
||||||
|
isLoading={isViewLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
|
|||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium text-gray-500 mb-1">可用庫存總計</span>
|
<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()}
|
{totals.available_stock.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
5
resources/js/ziggy.js
Normal file
5
resources/js/ziggy.js
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user