feat: 標準化全系統數值輸入欄位與擴充商品價格功能
1. UI 標準化: - 針對全系統數值輸入欄位統一加上 step='any' 以支援小數點。 - 表格形式 (Table) 的數值輸入欄位統一加上 text-right 靠右對齊。 - 修正 Components 與 Pages 中所有涉及金額與數量的輸入框。 2. 功能擴充與修正: - 擴充 Product 模型與相關 Dialog 以支援多種價格設定。 - 修正 Inventory/GoodsReceipt/Create.tsx 未使用的變數錯誤。 - 優化庫存相關頁面的 UI 一致性。 3. 其他: - 更新相關的 Type 定義與 Controller 邏輯。
This commit is contained in:
@@ -796,7 +796,42 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Calendar } from "lucide-react";
|
import { Calendar } from "lucide-react";
|
||||||
import { Input } from "@/Components/ui/input";
|
|
||||||
|
## 11.7 金額與數字輸入規範
|
||||||
|
|
||||||
|
所有涉及金額(單價、成本、總價)的輸入框,應遵循以下規範以確保操作體驗一致:
|
||||||
|
|
||||||
|
1. **HTML 屬性**:
|
||||||
|
* `type="number"`
|
||||||
|
* `min="0"` (除非業務邏輯允許負數)
|
||||||
|
* `step="any"` (設置為 `any` 可允許任意小數,且瀏覽器預設按上下鍵時會增減 **1** 並保留小數部分,例如 37.2 -> 38.2)
|
||||||
|
* **步進值 (Step)**: 金額與數量輸入框均應設定 `step="any"`,以支援小數點輸入(除非業務邏輯強制整數)。
|
||||||
|
* `placeholder="0"`
|
||||||
|
2. **樣式類別**:
|
||||||
|
* 預設靠左對齊 (不需要 `text-right`),亦可依版面需求調整。
|
||||||
|
|
||||||
|
### 9.2 對齊方式 (Alignment)
|
||||||
|
|
||||||
|
依據欄位所在的情境區分對齊方式:
|
||||||
|
|
||||||
|
- **明細列表/表格 (Details/Table)**:金額與數量欄位一律 **靠右對齊 (text-right)**。
|
||||||
|
- 包含:採購單明細、庫存盤點表、調撥單明細等 Table 內的輸入框。
|
||||||
|
- **一般表單/新增欄位 (Form/Input)**:金額與數量欄位一律 **靠左對齊 (text-left)**。
|
||||||
|
- 包含:商品資料設定、新增表單中的獨立欄位。亦可依版面需求調整。
|
||||||
|
3. **行為邏輯**:
|
||||||
|
* 輸入時允許輸入小數點。
|
||||||
|
* 鍵盤上下鍵調整時,瀏覽器會預設增減 1 (搭配 `step="any"`)。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={price}
|
||||||
|
onChange={(e) => setPrice(parseFloat(e.target.value) || 0)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||||
|
|||||||
@@ -181,6 +181,16 @@ class AdjustDocController extends Controller
|
|||||||
{
|
{
|
||||||
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']);
|
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']);
|
||||||
|
|
||||||
|
// Pre-fetch relevant Inventory information (mainly for expiry date)
|
||||||
|
$inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed()
|
||||||
|
->where('warehouse_id', $doc->warehouse_id)
|
||||||
|
->whereIn('product_id', $doc->items->pluck('product_id'))
|
||||||
|
->whereIn('batch_number', $doc->items->pluck('batch_number'))
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(function ($inv) {
|
||||||
|
return [$inv->product_id . '-' . $inv->batch_number => $inv];
|
||||||
|
});
|
||||||
|
|
||||||
$docData = [
|
$docData = [
|
||||||
'id' => (string) $doc->id,
|
'id' => (string) $doc->id,
|
||||||
'doc_no' => $doc->doc_no,
|
'doc_no' => $doc->doc_no,
|
||||||
@@ -193,13 +203,15 @@ class AdjustDocController extends Controller
|
|||||||
'created_by' => $doc->createdBy?->name,
|
'created_by' => $doc->createdBy?->name,
|
||||||
'count_doc_id' => $doc->count_doc_id ? (string)$doc->count_doc_id : null,
|
'count_doc_id' => $doc->count_doc_id ? (string)$doc->count_doc_id : null,
|
||||||
'count_doc_no' => $doc->countDoc?->doc_no,
|
'count_doc_no' => $doc->countDoc?->doc_no,
|
||||||
'items' => $doc->items->map(function ($item) {
|
'items' => $doc->items->map(function ($item) use ($inventoryMap) {
|
||||||
|
$inv = $inventoryMap->get($item->product_id . '-' . $item->batch_number);
|
||||||
return [
|
return [
|
||||||
'id' => (string) $item->id,
|
'id' => (string) $item->id,
|
||||||
'product_id' => (string) $item->product_id,
|
'product_id' => (string) $item->product_id,
|
||||||
'product_name' => $item->product->name,
|
'product_name' => $item->product->name,
|
||||||
'product_code' => $item->product->code,
|
'product_code' => $item->product->code,
|
||||||
'batch_number' => $item->batch_number,
|
'batch_number' => $item->batch_number,
|
||||||
|
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||||
'unit' => $item->product->baseUnit?->name,
|
'unit' => $item->product->baseUnit?->name,
|
||||||
'qty_before' => (float) $item->qty_before,
|
'qty_before' => (float) $item->qty_before,
|
||||||
'adjust_qty' => (float) $item->adjust_qty,
|
'adjust_qty' => (float) $item->adjust_qty,
|
||||||
|
|||||||
@@ -94,6 +94,16 @@ class CountDocController extends Controller
|
|||||||
{
|
{
|
||||||
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
|
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
|
||||||
|
|
||||||
|
// 預先抓取相關的 Inventory 資訊 (主要為了取得效期)
|
||||||
|
$inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed()
|
||||||
|
->where('warehouse_id', $doc->warehouse_id)
|
||||||
|
->whereIn('product_id', $doc->items->pluck('product_id'))
|
||||||
|
->whereIn('batch_number', $doc->items->pluck('batch_number'))
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(function ($inv) {
|
||||||
|
return [$inv->product_id . '-' . $inv->batch_number => $inv];
|
||||||
|
});
|
||||||
|
|
||||||
$docData = [
|
$docData = [
|
||||||
'id' => (string) $doc->id,
|
'id' => (string) $doc->id,
|
||||||
'doc_no' => $doc->doc_no,
|
'doc_no' => $doc->doc_no,
|
||||||
@@ -103,12 +113,16 @@ class CountDocController extends Controller
|
|||||||
'remarks' => $doc->remarks,
|
'remarks' => $doc->remarks,
|
||||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null,
|
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null,
|
||||||
'created_by' => $doc->createdBy?->name,
|
'created_by' => $doc->createdBy?->name,
|
||||||
'items' => $doc->items->map(function ($item) {
|
'items' => $doc->items->map(function ($item) use ($inventoryMap) {
|
||||||
|
$key = $item->product_id . '-' . $item->batch_number;
|
||||||
|
$inv = $inventoryMap->get($key);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (string) $item->id,
|
'id' => (string) $item->id,
|
||||||
'product_name' => $item->product->name,
|
'product_name' => $item->product->name,
|
||||||
'product_code' => $item->product->code,
|
'product_code' => $item->product->code,
|
||||||
'batch_number' => $item->batch_number,
|
'batch_number' => $item->batch_number,
|
||||||
|
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, // 新增效期
|
||||||
'unit' => $item->product->baseUnit?->name,
|
'unit' => $item->product->baseUnit?->name,
|
||||||
'system_qty' => (float) $item->system_qty,
|
'system_qty' => (float) $item->system_qty,
|
||||||
'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty,
|
'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty,
|
||||||
|
|||||||
@@ -131,16 +131,18 @@ class InventoryController extends Controller
|
|||||||
{
|
{
|
||||||
// ... (unchanged) ...
|
// ... (unchanged) ...
|
||||||
$products = Product::with(['baseUnit', 'largeUnit'])
|
$products = Product::with(['baseUnit', 'largeUnit'])
|
||||||
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate')
|
->select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
||||||
->get()
|
->get()
|
||||||
->map(function ($product) {
|
->map(function ($product) {
|
||||||
return [
|
return [
|
||||||
'id' => (string) $product->id,
|
'id' => (string) $product->id,
|
||||||
'name' => $product->name,
|
'name' => $product->name,
|
||||||
'code' => $product->code,
|
'code' => $product->code,
|
||||||
|
'barcode' => $product->barcode,
|
||||||
'baseUnit' => $product->baseUnit?->name ?? '個',
|
'baseUnit' => $product->baseUnit?->name ?? '個',
|
||||||
'largeUnit' => $product->largeUnit?->name, // 可能為 null
|
'largeUnit' => $product->largeUnit?->name, // 可能為 null
|
||||||
'conversionRate' => (float) $product->conversion_rate,
|
'conversionRate' => (float) $product->conversion_rate,
|
||||||
|
'costPrice' => (float) $product->cost_price,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ class ProductController extends Controller
|
|||||||
] : null,
|
] : null,
|
||||||
'conversionRate' => (float) $product->conversion_rate,
|
'conversionRate' => (float) $product->conversion_rate,
|
||||||
'location' => $product->location,
|
'location' => $product->location,
|
||||||
|
'cost_price' => (float) $product->cost_price,
|
||||||
|
'price' => (float) $product->price,
|
||||||
|
'member_price' => (float) $product->member_price,
|
||||||
|
'wholesale_price' => (float) $product->wholesale_price,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,7 +130,12 @@ class ProductController extends Controller
|
|||||||
'large_unit_id' => 'nullable|exists:units,id',
|
'large_unit_id' => 'nullable|exists:units,id',
|
||||||
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||||
'purchase_unit_id' => 'nullable|exists:units,id',
|
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||||
|
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||||
'location' => 'nullable|string|max:255',
|
'location' => 'nullable|string|max:255',
|
||||||
|
'cost_price' => 'nullable|numeric|min:0',
|
||||||
|
'price' => 'nullable|numeric|min:0',
|
||||||
|
'member_price' => 'nullable|numeric|min:0',
|
||||||
|
'wholesale_price' => 'nullable|numeric|min:0',
|
||||||
], [
|
], [
|
||||||
'code.required' => '商品代號為必填',
|
'code.required' => '商品代號為必填',
|
||||||
'code.max' => '商品代號最多 8 碼',
|
'code.max' => '商品代號最多 8 碼',
|
||||||
@@ -142,6 +151,14 @@ class ProductController extends Controller
|
|||||||
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||||
'conversion_rate.numeric' => '換算率必須為數字',
|
'conversion_rate.numeric' => '換算率必須為數字',
|
||||||
'conversion_rate.min' => '換算率最小為 0.0001',
|
'conversion_rate.min' => '換算率最小為 0.0001',
|
||||||
|
'cost_price.numeric' => '成本價必須為數字',
|
||||||
|
'cost_price.min' => '成本價不能小於 0',
|
||||||
|
'price.numeric' => '售價必須為數字',
|
||||||
|
'price.min' => '售價不能小於 0',
|
||||||
|
'member_price.numeric' => '會員價必須為數字',
|
||||||
|
'member_price.min' => '會員價不能小於 0',
|
||||||
|
'wholesale_price.numeric' => '批發價必須為數字',
|
||||||
|
'wholesale_price.min' => '批發價不能小於 0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$product = Product::create($validated);
|
$product = Product::create($validated);
|
||||||
@@ -165,7 +182,12 @@ class ProductController extends Controller
|
|||||||
'large_unit_id' => 'nullable|exists:units,id',
|
'large_unit_id' => 'nullable|exists:units,id',
|
||||||
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||||
'purchase_unit_id' => 'nullable|exists:units,id',
|
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||||
|
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||||
'location' => 'nullable|string|max:255',
|
'location' => 'nullable|string|max:255',
|
||||||
|
'cost_price' => 'nullable|numeric|min:0',
|
||||||
|
'price' => 'nullable|numeric|min:0',
|
||||||
|
'member_price' => 'nullable|numeric|min:0',
|
||||||
|
'wholesale_price' => 'nullable|numeric|min:0',
|
||||||
], [
|
], [
|
||||||
'code.required' => '商品代號為必填',
|
'code.required' => '商品代號為必填',
|
||||||
'code.max' => '商品代號最多 8 碼',
|
'code.max' => '商品代號最多 8 碼',
|
||||||
@@ -181,6 +203,14 @@ class ProductController extends Controller
|
|||||||
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||||
'conversion_rate.numeric' => '換算率必須為數字',
|
'conversion_rate.numeric' => '換算率必須為數字',
|
||||||
'conversion_rate.min' => '換算率最小為 0.0001',
|
'conversion_rate.min' => '換算率最小為 0.0001',
|
||||||
|
'cost_price.numeric' => '成本價必須為數字',
|
||||||
|
'cost_price.min' => '成本價不能小於 0',
|
||||||
|
'price.numeric' => '售價必須為數字',
|
||||||
|
'price.min' => '售價不能小於 0',
|
||||||
|
'member_price.numeric' => '會員價必須為數字',
|
||||||
|
'member_price.min' => '會員價不能小於 0',
|
||||||
|
'wholesale_price.numeric' => '批發價必須為數字',
|
||||||
|
'wholesale_price.min' => '批發價不能小於 0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$product->update($validated);
|
$product->update($validated);
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ class TransferOrderController extends Controller
|
|||||||
'product_name' => $item->product->name,
|
'product_name' => $item->product->name,
|
||||||
'product_code' => $item->product->code,
|
'product_code' => $item->product->code,
|
||||||
'batch_number' => $item->batch_number,
|
'batch_number' => $item->batch_number,
|
||||||
|
'expiry_date' => $stock && $stock->expiry_date ? $stock->expiry_date->format('Y-m-d') : null,
|
||||||
'unit' => $item->product->baseUnit?->name,
|
'unit' => $item->product->baseUnit?->name,
|
||||||
'quantity' => (float) $item->quantity,
|
'quantity' => (float) $item->quantity,
|
||||||
'max_quantity' => $item->snapshot_quantity ? (float) $item->snapshot_quantity : ($stock ? (float) $stock->quantity : 0.0),
|
'max_quantity' => $item->snapshot_quantity ? (float) $item->snapshot_quantity : ($stock ? (float) $stock->quantity : 0.0),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Modules\Inventory\Exports;
|
|||||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
|
||||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
// use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||||
|
|
||||||
class ProductTemplateExport implements WithHeadings, WithColumnFormatting
|
class ProductTemplateExport implements WithHeadings, WithColumnFormatting
|
||||||
@@ -22,6 +22,10 @@ class ProductTemplateExport implements WithHeadings, WithColumnFormatting
|
|||||||
'基本單位',
|
'基本單位',
|
||||||
'大單位',
|
'大單位',
|
||||||
'換算率',
|
'換算率',
|
||||||
|
'成本價',
|
||||||
|
'售價',
|
||||||
|
'會員價',
|
||||||
|
'批發價',
|
||||||
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
|||||||
'large_unit_id' => $largeUnitId,
|
'large_unit_id' => $largeUnitId,
|
||||||
'conversion_rate' => $row['換算率'] ?? null,
|
'conversion_rate' => $row['換算率'] ?? null,
|
||||||
'purchase_unit_id' => null,
|
'purchase_unit_id' => null,
|
||||||
|
'cost_price' => $row['成本價'] ?? null,
|
||||||
|
'price' => $row['售價'] ?? null,
|
||||||
|
'member_price' => $row['會員價'] ?? null,
|
||||||
|
'wholesale_price' => $row['批發價'] ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +104,10 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
|||||||
}],
|
}],
|
||||||
|
|
||||||
'換算率' => ['nullable', 'numeric', 'min:0.0001', 'required_with:大單位'],
|
'換算率' => ['nullable', 'numeric', 'min:0.0001', 'required_with:大單位'],
|
||||||
|
'成本價' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'售價' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'會員價' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'批發價' => ['nullable', 'numeric', 'min:0'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class Product extends Model
|
|||||||
'conversion_rate',
|
'conversion_rate',
|
||||||
'purchase_unit_id',
|
'purchase_unit_id',
|
||||||
'location',
|
'location',
|
||||||
|
'cost_price',
|
||||||
|
'price',
|
||||||
|
'member_price',
|
||||||
|
'wholesale_price',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ use Illuminate\Foundation\Application;
|
|||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Middleware\TrustProxies;
|
use Illuminate\Http\Middleware\TrustProxies;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
use Spatie\Permission\Exceptions\UnauthorizedException;
|
use Spatie\Permission\Exceptions\UnauthorizedException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -37,14 +38,24 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
// 處理 Spatie Permission 的 UnauthorizedException
|
// 處理 Spatie Permission 的 UnauthorizedException
|
||||||
$exceptions->render(function (UnauthorizedException $e) {
|
$exceptions->render(function (UnauthorizedException $e) {
|
||||||
return Inertia::render('Error/403')->toResponse(request())->setStatusCode(403);
|
return Inertia::render('Error/Index', ['status' => 403])
|
||||||
|
->toResponse(request())
|
||||||
|
->setStatusCode(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 處理一般的 403 HttpException
|
// 處理 404 NotFoundHttpException
|
||||||
|
$exceptions->render(function (NotFoundHttpException $e) {
|
||||||
|
return Inertia::render('Error/Index', ['status' => 404])
|
||||||
|
->toResponse(request())
|
||||||
|
->setStatusCode(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 處理其他一般的 HttpException (包含 403, 419, 429, 500, 503 等)
|
||||||
$exceptions->render(function (HttpException $e) {
|
$exceptions->render(function (HttpException $e) {
|
||||||
if ($e->getStatusCode() === 403) {
|
$status = $e->getStatusCode();
|
||||||
return Inertia::render('Error/403')->toResponse(request())->setStatusCode(403);
|
return Inertia::render('Error/Index', ['status' => $status])
|
||||||
}
|
->toResponse(request())
|
||||||
|
->setStatusCode($status);
|
||||||
});
|
});
|
||||||
})->create();
|
})->create();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('products', function (Blueprint $table) {
|
||||||
|
$table->decimal('cost_price', 12, 2)->nullable()->after('location');
|
||||||
|
$table->decimal('price', 12, 2)->nullable()->after('cost_price');
|
||||||
|
$table->decimal('member_price', 12, 2)->nullable()->after('price');
|
||||||
|
$table->decimal('wholesale_price', 12, 2)->nullable()->after('member_price');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('products', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['cost_price', 'price', 'member_price', 'wholesale_price']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
161
resources/js/Components/Inventory/ScannerInput.tsx
Normal file
161
resources/js/Components/Inventory/ScannerInput.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||||
|
import { Input } from '@/Components/ui/input';
|
||||||
|
import { Label } from '@/Components/ui/label';
|
||||||
|
import { Switch } from '@/Components/ui/switch';
|
||||||
|
import { RefreshCcw, Scan, Zap } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ScannerInputProps {
|
||||||
|
onScan: (code: string, mode: 'continuous' | 'single') => void;
|
||||||
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScannerInput({ onScan, className, placeholder = "點擊此處並掃描..." }: ScannerInputProps) {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [isContinuous, setIsContinuous] = useState(true);
|
||||||
|
const [lastAction, setLastAction] = useState<{ message: string; type: 'success' | 'info' | 'error'; time: number } | null>(null);
|
||||||
|
const [isFlashing, setIsFlashing] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Focus input on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Audio context for beep sound
|
||||||
|
const playBeep = (type: 'success' | 'error' = 'success') => {
|
||||||
|
try {
|
||||||
|
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
if (!AudioContext) return;
|
||||||
|
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5
|
||||||
|
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1);
|
||||||
|
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(ctx.currentTime + 0.1);
|
||||||
|
} else {
|
||||||
|
oscillator.type = 'sawtooth';
|
||||||
|
oscillator.frequency.setValueAtTime(110, ctx.currentTime); // Low buzz
|
||||||
|
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(ctx.currentTime + 0.2);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Audio playback failed', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (code.trim()) {
|
||||||
|
handleScanSubmit(code.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanSubmit = (scannedCode: string) => {
|
||||||
|
// Trigger parent callback
|
||||||
|
onScan(scannedCode, isContinuous ? 'continuous' : 'single');
|
||||||
|
|
||||||
|
// UI Feedback
|
||||||
|
playBeep('success');
|
||||||
|
setIsFlashing(true);
|
||||||
|
setTimeout(() => setIsFlashing(false), 300);
|
||||||
|
|
||||||
|
// Show last action tip
|
||||||
|
setLastAction({
|
||||||
|
message: `已掃描: ${scannedCode}`,
|
||||||
|
type: 'success',
|
||||||
|
time: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear input and focus
|
||||||
|
setCode('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Public method to set last action message from parent (if needed for more context like product name)
|
||||||
|
// For now we just use internal state
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("bg-white p-4 rounded-xl border-2 shadow-sm transition-all relative overflow-hidden", isFlashing ? "border-green-500 bg-green-50" : "border-primary/20", className)}>
|
||||||
|
|
||||||
|
{/* Background flashy effect */}
|
||||||
|
<div className={cn("absolute inset-0 bg-green-400/20 transition-opacity duration-300 pointer-events-none", isFlashing ? "opacity-100" : "opacity-0")} />
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-4 items-center justify-between relative z-10">
|
||||||
|
|
||||||
|
{/* Left: Input Area */}
|
||||||
|
<div className="flex-1 w-full relative">
|
||||||
|
<Scan className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 h-5 w-5" />
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="pl-10 h-12 text-lg font-mono border-gray-300 focus:border-primary focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
{/* Continuous Mode Badge */}
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||||
|
{isContinuous && (
|
||||||
|
<div className="bg-primary/10 text-primary text-xs font-bold px-2 py-1 rounded-md flex items-center gap-1">
|
||||||
|
<Zap className="h-3 w-3 fill-primary" />
|
||||||
|
連續模式
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Controls & Status */}
|
||||||
|
<div className="flex items-center gap-6 w-full md:w-auto justify-between md:justify-end">
|
||||||
|
|
||||||
|
{/* Last Action Display */}
|
||||||
|
<div className="flex-1 md:flex-none text-right min-h-[40px] flex flex-col justify-center">
|
||||||
|
{lastAction && (Date.now() - lastAction.time < 5000) ? (
|
||||||
|
<div className="animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
|
<span className="text-sm font-bold text-gray-800 block">{lastAction.message}</span>
|
||||||
|
{isContinuous && <span className="text-xs text-green-600 font-bold block">自動加總 (+1)</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">等待掃描...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-8 w-px bg-gray-200 hidden md:block"></div>
|
||||||
|
|
||||||
|
{/* Toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="continuous-mode" className={cn("text-sm font-bold cursor-pointer select-none", isContinuous ? "text-primary" : "text-gray-500")}>
|
||||||
|
連續加總
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="continuous-mode"
|
||||||
|
checked={isContinuous}
|
||||||
|
onCheckedChange={setIsContinuous}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-gray-400 px-1">
|
||||||
|
<RefreshCcw className="h-3 w-3" />
|
||||||
|
<span>提示:開啟連續模式時,掃描相同商品會自動將數量 +1;關閉則會視為新批號輸入。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,10 @@ export default function ProductDialog({
|
|||||||
conversion_rate: "",
|
conversion_rate: "",
|
||||||
purchase_unit_id: "",
|
purchase_unit_id: "",
|
||||||
location: "",
|
location: "",
|
||||||
|
cost_price: "",
|
||||||
|
price: "",
|
||||||
|
member_price: "",
|
||||||
|
wholesale_price: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,6 +69,10 @@ export default function ProductDialog({
|
|||||||
conversion_rate: product.conversionRate ? product.conversionRate.toString() : "",
|
conversion_rate: product.conversionRate ? product.conversionRate.toString() : "",
|
||||||
purchase_unit_id: product.purchaseUnitId?.toString() || "",
|
purchase_unit_id: product.purchaseUnitId?.toString() || "",
|
||||||
location: product.location || "",
|
location: product.location || "",
|
||||||
|
cost_price: product.cost_price?.toString() || "",
|
||||||
|
price: product.price?.toString() || "",
|
||||||
|
member_price: product.member_price?.toString() || "",
|
||||||
|
wholesale_price: product.wholesale_price?.toString() || "",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
reset();
|
reset();
|
||||||
@@ -235,6 +243,72 @@ export default function ProductDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 價格設定區塊 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium border-b pb-2">價格設定</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cost_price">成本價</Label>
|
||||||
|
<Input
|
||||||
|
id="cost_price"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={data.cost_price}
|
||||||
|
onChange={(e) => setData("cost_price", e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className={errors.cost_price ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.cost_price && <p className="text-sm text-red-500">{errors.cost_price}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="price">售價</Label>
|
||||||
|
<Input
|
||||||
|
id="price"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={data.price}
|
||||||
|
onChange={(e) => setData("price", e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className={errors.price ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.price && <p className="text-sm text-red-500">{errors.price}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="member_price">會員價</Label>
|
||||||
|
<Input
|
||||||
|
id="member_price"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={data.member_price}
|
||||||
|
onChange={(e) => setData("member_price", e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className={errors.member_price ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.member_price && <p className="text-sm text-red-500">{errors.member_price}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wholesale_price">批發價</Label>
|
||||||
|
<Input
|
||||||
|
id="wholesale_price"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={data.wholesale_price}
|
||||||
|
onChange={(e) => setData("wholesale_price", e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className={errors.wholesale_price ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.wholesale_price && <p className="text-sm text-red-500">{errors.wholesale_price}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 單位設定區塊 */}
|
{/* 單位設定區塊 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium border-b pb-2">單位設定</h3>
|
<h3 className="text-lg font-medium border-b pb-2">單位設定</h3>
|
||||||
@@ -278,7 +352,7 @@ export default function ProductDialog({
|
|||||||
<Input
|
<Input
|
||||||
id="conversion_rate"
|
id="conversion_rate"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.0001"
|
step="any"
|
||||||
value={data.conversion_rate}
|
value={data.conversion_rate}
|
||||||
onChange={(e) => setData("conversion_rate", e.target.value)}
|
onChange={(e) => setData("conversion_rate", e.target.value)}
|
||||||
placeholder={data.large_unit_id && data.base_unit_id ? `1 ${units.find(u => u.id.toString() === data.large_unit_id)?.name} = ? ${units.find(u => u.id.toString() === data.base_unit_id)?.name}` : ""}
|
placeholder={data.large_unit_id && data.base_unit_id ? `1 ${units.find(u => u.id.toString() === data.large_unit_id)?.name} = ? ${units.find(u => u.id.toString() === data.base_unit_id)?.name}` : ""}
|
||||||
|
|||||||
@@ -110,13 +110,13 @@ export function PurchaseOrderItemsTable({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="any"
|
||||||
value={item.quantity === 0 ? "" : Math.floor(item.quantity)}
|
value={item.quantity === 0 ? "" : Math.floor(item.quantity)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onItemChange?.(index, "quantity", Math.floor(Number(e.target.value)))
|
onItemChange?.(index, "quantity", Number(e.target.value))
|
||||||
}
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className="text-left w-24"
|
className="text-right w-24"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -189,13 +189,13 @@ export function PurchaseOrderItemsTable({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="any"
|
||||||
value={item.subtotal || ""}
|
value={item.subtotal || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onItemChange?.(index, "subtotal", Number(e.target.value))
|
onItemChange?.(index, "subtotal", Number(e.target.value))
|
||||||
}
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className={`text-left w-32 ${
|
className={`text-right w-32 ${
|
||||||
// 如果有數量但沒有金額,顯示錯誤樣式
|
// 如果有數量但沒有金額,顯示錯誤樣式
|
||||||
item.quantity > 0 && (!item.subtotal || item.subtotal <= 0)
|
item.quantity > 0 && (!item.subtotal || item.subtotal <= 0)
|
||||||
? "border-red-400 bg-red-50 focus-visible:ring-red-500"
|
? "border-red-400 bg-red-50 focus-visible:ring-red-500"
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export default function EditSafetyStockDialog({
|
|||||||
id="safetyStock"
|
id="safetyStock"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
step="any"
|
||||||
value={safetyStock}
|
value={safetyStock}
|
||||||
onChange={(e) => setSafetyStock(parseInt(e.target.value) || 0)}
|
onChange={(e) => setSafetyStock(parseInt(e.target.value) || 0)}
|
||||||
placeholder="請輸入安全庫存量"
|
placeholder="請輸入安全庫存量"
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export default function UtilityFeeDialog({
|
|||||||
<Input
|
<Input
|
||||||
id="amount"
|
id="amount"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="any"
|
||||||
value={data.amount}
|
value={data.amount}
|
||||||
onChange={(e) => setData("amount", e.target.value)}
|
onChange={(e) => setData("amount", e.target.value)}
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ export default function AddSupplyProductDialog({
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
placeholder="輸入價格"
|
placeholder="輸入價格"
|
||||||
value={lastPrice}
|
value={lastPrice}
|
||||||
onChange={(e) => setLastPrice(e.target.value)}
|
onChange={(e) => setLastPrice(e.target.value)}
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export default function EditSupplyProductDialog({
|
|||||||
<Label className="text-muted-foreground text-xs">上次採購單價 / {product.baseUnit || "單位"}</Label>
|
<Label className="text-muted-foreground text-xs">上次採購單價 / {product.baseUnit || "單位"}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
placeholder="輸入價格"
|
placeholder="輸入價格"
|
||||||
value={lastPrice}
|
value={lastPrice}
|
||||||
onChange={(e) => setLastPrice(e.target.value)}
|
onChange={(e) => setLastPrice(e.target.value)}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export default function BatchAdjustmentModal({
|
|||||||
<Input
|
<Input
|
||||||
id="adj-qty"
|
id="adj-qty"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="any"
|
||||||
min="0"
|
min="0"
|
||||||
value={quantity}
|
value={quantity}
|
||||||
onChange={(e) => setQuantity(e.target.value)}
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default function InventoryAdjustmentDialog({
|
|||||||
<Input
|
<Input
|
||||||
id="quantity"
|
id="quantity"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="any"
|
||||||
value={data.quantity === 0 ? "" : data.quantity}
|
value={data.quantity === 0 ? "" : data.quantity}
|
||||||
onChange={e => setData("quantity", Number(e.target.value))}
|
onChange={e => setData("quantity", Number(e.target.value))}
|
||||||
placeholder="請輸入數量"
|
placeholder="請輸入數量"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AlertTriangle, Edit, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
|
import { AlertTriangle, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -28,13 +28,12 @@ import {
|
|||||||
import { GroupedInventory } from "@/types/warehouse";
|
import { GroupedInventory } from "@/types/warehouse";
|
||||||
import { formatDate } from "@/utils/format";
|
import { formatDate } from "@/utils/format";
|
||||||
import { Can } from "@/Components/Permission/Can";
|
import { Can } from "@/Components/Permission/Can";
|
||||||
import BatchAdjustmentModal from "./BatchAdjustmentModal";
|
|
||||||
|
|
||||||
interface InventoryTableProps {
|
interface InventoryTableProps {
|
||||||
inventories: GroupedInventory[];
|
inventories: GroupedInventory[];
|
||||||
onView: (id: string) => void;
|
onView: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onAdjust: (batchId: string, data: { operation: string; quantity: number; reason: string }) => void;
|
|
||||||
onViewProduct?: (productId: string) => void;
|
onViewProduct?: (productId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,19 +41,12 @@ export default function InventoryTable({
|
|||||||
inventories,
|
inventories,
|
||||||
onView,
|
onView,
|
||||||
onDelete,
|
onDelete,
|
||||||
onAdjust,
|
|
||||||
onViewProduct,
|
onViewProduct,
|
||||||
}: InventoryTableProps) {
|
}: InventoryTableProps) {
|
||||||
// 每個商品的展開/折疊狀態
|
// 每個商品的展開/折疊狀態
|
||||||
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
|
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 調整彈窗狀態
|
|
||||||
const [adjustmentTarget, setAdjustmentTarget] = useState<{
|
|
||||||
id: string;
|
|
||||||
batchNumber: string;
|
|
||||||
currentQuantity: number;
|
|
||||||
productName: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
if (inventories.length === 0) {
|
if (inventories.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -244,22 +236,7 @@ export default function InventoryTable({
|
|||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Can permission="inventory.adjust">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setAdjustmentTarget({
|
|
||||||
id: batch.id,
|
|
||||||
batchNumber: batch.batchNumber,
|
|
||||||
currentQuantity: batch.quantity,
|
|
||||||
productName: group.productName
|
|
||||||
})}
|
|
||||||
className="button-outlined-primary"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Can>
|
|
||||||
<Can permission="inventory.delete">
|
<Can permission="inventory.delete">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -302,17 +279,7 @@ export default function InventoryTable({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<BatchAdjustmentModal
|
|
||||||
isOpen={!!adjustmentTarget}
|
|
||||||
onClose={() => setAdjustmentTarget(null)}
|
|
||||||
batch={adjustmentTarget || undefined}
|
|
||||||
onConfirm={(data) => {
|
|
||||||
if (adjustmentTarget) {
|
|
||||||
onAdjust(adjustmentTarget.id, data);
|
|
||||||
setAdjustmentTarget(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export default function AddSafetyStockDialog({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="any"
|
||||||
value={quantity || ""}
|
value={quantity || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateQuantity(productId, parseFloat(e.target.value) || 0)
|
updateQuantity(productId, parseFloat(e.target.value) || 0)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function EditSafetyStockDialog({
|
|||||||
id="edit-safety"
|
id="edit-safety"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="any"
|
||||||
value={safetyStock}
|
value={safetyStock}
|
||||||
onChange={(e) => setSafetyStock(parseFloat(e.target.value) || 0)}
|
onChange={(e) => setSafetyStock(parseFloat(e.target.value) || 0)}
|
||||||
className="button-outlined-primary"
|
className="button-outlined-primary"
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function SearchableSelect({
|
|||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="p-0 z-[9999]"
|
className="p-0 z-[9999]"
|
||||||
align="start"
|
align="start"
|
||||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
style={{ width: "var(--radix-popover-trigger-width)", minWidth: "12rem" }}
|
||||||
>
|
>
|
||||||
<Command>
|
<Command>
|
||||||
{shouldShowSearch && (
|
{shouldShowSearch && (
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Link } from "@inertiajs/react";
|
|
||||||
import { ShieldAlert, Home } from "lucide-react";
|
|
||||||
|
|
||||||
export default function Error403() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center p-6">
|
|
||||||
<div className="max-w-md w-full text-center">
|
|
||||||
{/* 圖示 */}
|
|
||||||
<div className="mb-6 flex justify-center">
|
|
||||||
<div className="w-24 h-24 bg-red-100 rounded-full flex items-center justify-center">
|
|
||||||
<ShieldAlert className="w-12 h-12 text-red-500" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 標題 */}
|
|
||||||
<h1 className="text-3xl font-bold text-slate-900 mb-2">
|
|
||||||
無此權限
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* 說明 */}
|
|
||||||
<p className="text-slate-600 mb-8">
|
|
||||||
您沒有存取此頁面的權限,請洽系統管理員。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 返回按鈕 */}
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-primary-main text-white rounded-lg hover:bg-primary-dark transition-colors"
|
|
||||||
>
|
|
||||||
<Home className="w-5 h-5" />
|
|
||||||
返回首頁
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
99
resources/js/Pages/Error/Index.tsx
Normal file
99
resources/js/Pages/Error/Index.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Head, Link } from "@inertiajs/react";
|
||||||
|
import { ShieldAlert, FileQuestion, ServerCrash, HardHat, Home, ArrowLeft } from "lucide-react";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorPage({ status, message }: Props) {
|
||||||
|
const errorDetails: Record<number, { title: string; description: string; icon: any; color: string }> = {
|
||||||
|
403: {
|
||||||
|
title: "無此權限 (403)",
|
||||||
|
description: "抱歉,您沒有權限存取此頁面。如果您認為這是個錯誤,請聯繫系統管理員。",
|
||||||
|
icon: ShieldAlert,
|
||||||
|
color: "text-yellow-500 bg-yellow-100 border-yellow-200",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
title: "頁面未找到 (404)",
|
||||||
|
description: "抱歉,我們找不到您要訪問的頁面。它可能已被移除、更改名稱或暫時不可用。",
|
||||||
|
icon: FileQuestion,
|
||||||
|
color: "text-blue-500 bg-blue-100 border-blue-200",
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
title: "伺服器錯誤 (500)",
|
||||||
|
description: "抱歉,伺服器發生了內部錯誤。我們的技術團隊已經收到通知,正在努力修復中。",
|
||||||
|
icon: ServerCrash,
|
||||||
|
color: "text-red-500 bg-red-100 border-red-200",
|
||||||
|
},
|
||||||
|
503: {
|
||||||
|
title: "服務維護中 (503)",
|
||||||
|
description: "抱歉,系統目前正在進行維護。請稍後再試。",
|
||||||
|
icon: HardHat,
|
||||||
|
color: "text-orange-500 bg-orange-100 border-orange-200",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultError = {
|
||||||
|
title: "發生錯誤",
|
||||||
|
description: message || "發生了未知的錯誤。",
|
||||||
|
icon: ShieldAlert,
|
||||||
|
color: "text-gray-500 bg-gray-100 border-gray-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
const details = errorDetails[status] || defaultError;
|
||||||
|
const Icon = details.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center p-4">
|
||||||
|
<Head title={details.title} />
|
||||||
|
|
||||||
|
<div className="max-w-md w-full text-center slide-in-bottom"> {/* slide-in-bottom need to be defined in global css or just use simple animation */}
|
||||||
|
|
||||||
|
{/* Icon Circle */}
|
||||||
|
<div className="mb-8 flex justify-center relative">
|
||||||
|
<div className={`absolute inset-0 rounded-full animate-ping opacity-20 ${details.color.split(' ')[1]}`}></div>
|
||||||
|
<div className={`relative w-24 h-24 rounded-full flex items-center justify-center border-4 shadow-xl ${details.color}`}>
|
||||||
|
<Icon className="w-12 h-12" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Content */}
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-3 tracking-tight">
|
||||||
|
{details.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-slate-500 mb-10 text-lg leading-relaxed">
|
||||||
|
{details.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="gap-2 min-w-[140px] border-slate-300 hover:bg-slate-100 text-slate-700"
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" /> 返回上一頁
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Link href={route('dashboard')}>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
className="gap-2 min-w-[140px] shadow-lg shadow-primary/20"
|
||||||
|
>
|
||||||
|
<Home className="h-5 w-5" /> 返回首頁
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 text-sm text-slate-400 font-mono">
|
||||||
|
Error Code: {status} | Star ERP System
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,8 +48,10 @@ interface AdjItem {
|
|||||||
qty_before: number | string;
|
qty_before: number | string;
|
||||||
adjust_qty: number | string;
|
adjust_qty: number | string;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
expiry_date?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface AdjDoc {
|
interface AdjDoc {
|
||||||
id: string;
|
id: string;
|
||||||
doc_no: string;
|
doc_no: string;
|
||||||
@@ -155,6 +157,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
qty_before: inv.quantity || 0,
|
qty_before: inv.quantity || 0,
|
||||||
adjust_qty: 0,
|
adjust_qty: 0,
|
||||||
notes: '',
|
notes: '',
|
||||||
|
expiry_date: inv.expiry_date,
|
||||||
});
|
});
|
||||||
addedCount++;
|
addedCount++;
|
||||||
}
|
}
|
||||||
@@ -409,9 +412,10 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
onCheckedChange={() => toggleSelectAll()}
|
onCheckedChange={() => toggleSelectAll()}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600">商品代號</TableHead>
|
|
||||||
<TableHead className="font-medium text-grey-600">品名</TableHead>
|
<TableHead className="font-medium text-grey-600">品名</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600">批號</TableHead>
|
<TableHead className="font-medium text-grey-600">批號</TableHead>
|
||||||
|
<TableHead className="font-medium text-grey-600">效期</TableHead>
|
||||||
<TableHead className="text-right font-medium text-grey-600 pr-6">現有庫存</TableHead>
|
<TableHead className="text-right font-medium text-grey-600 pr-6">現有庫存</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -447,9 +451,10 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
onCheckedChange={() => toggleSelect(key)}
|
onCheckedChange={() => toggleSelect(key)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-sm text-grey-1">{inv.product_code}</TableCell>
|
|
||||||
<TableCell className="font-semibold text-grey-0">{inv.product_name}</TableCell>
|
<TableCell className="font-semibold text-grey-0">{inv.product_name}</TableCell>
|
||||||
<TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell>
|
<TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono text-grey-2">{inv.expiry_date || '-'}</TableCell>
|
||||||
<TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
|
<TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
@@ -532,7 +537,14 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
|
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-grey-600 font-mono text-sm">{item.batch_number || '-'}</TableCell>
|
<TableCell className="text-grey-600 font-mono text-sm">
|
||||||
|
<div>{item.batch_number || '-'}</div>
|
||||||
|
{item.expiry_date && (
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
效期: {item.expiry_date}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-center text-grey-500">{item.unit}</TableCell>
|
<TableCell className="text-center text-grey-500">{item.unit}</TableCell>
|
||||||
<TableCell className="text-right font-medium text-grey-400">
|
<TableCell className="text-right font-medium text-grey-400">
|
||||||
{item.qty_before}
|
{item.qty_before}
|
||||||
@@ -542,8 +554,8 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
<div className="flex justify-end pr-2">
|
<div className="flex justify-end pr-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="any"
|
||||||
className="text-right h-9 w-32 font-medium"
|
className="h-9 w-32 font-medium text-right"
|
||||||
value={item.adjust_qty}
|
value={item.adjust_qty}
|
||||||
onChange={e => updateItem(index, 'adjust_qty', e.target.value)}
|
onChange={e => updateItem(index, 'adjust_qty', e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -569,9 +581,9 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
{!isReadOnly && !doc.count_doc_id && (
|
{!isReadOnly && !doc.count_doc_id && (
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="icon"
|
||||||
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 p-0"
|
className="button-outlined-error h-8 w-8"
|
||||||
onClick={() => removeItem(index)}
|
onClick={() => removeItem(index)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
@@ -276,7 +276,14 @@ export default function Show({ doc }: any) {
|
|||||||
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
|
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
|
<TableCell className="text-sm font-mono">
|
||||||
|
<div>{item.batch_number || '-'}</div>
|
||||||
|
{item.expiry_date && (
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
效期: {item.expiry_date}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right font-medium">{Number(item.system_qty)}</TableCell>
|
<TableCell className="text-right font-medium">{Number(item.system_qty)}</TableCell>
|
||||||
<TableCell className="text-right px-1 py-3">
|
<TableCell className="text-right px-1 py-3">
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
@@ -284,12 +291,12 @@ export default function Show({ doc }: any) {
|
|||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="any"
|
||||||
value={formItem.counted_qty ?? ''}
|
value={formItem.counted_qty ?? ''}
|
||||||
onChange={(e) => updateItem(index, 'counted_qty', e.target.value)}
|
onChange={(e) => updateItem(index, 'counted_qty', e.target.value)}
|
||||||
onWheel={(e: any) => e.target.blur()}
|
onWheel={(e: any) => e.target.blur()}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
className="h-9 text-right font-medium focus:ring-primary-main"
|
className="h-9 font-medium focus:ring-primary-main text-right"
|
||||||
placeholder="盤點..."
|
placeholder="盤點..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,13 +38,7 @@ import { STATUS_CONFIG } from '@/constants/purchase-order';
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface BatchItem {
|
|
||||||
inventoryId: string;
|
|
||||||
batchNumber: string;
|
|
||||||
originCountry: string;
|
|
||||||
expiryDate: string | null;
|
|
||||||
quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 待進貨採購單 Item 介面
|
// 待進貨採購單 Item 介面
|
||||||
interface PendingPOItem {
|
interface PendingPOItem {
|
||||||
@@ -207,13 +201,12 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Batch management
|
// Batch management
|
||||||
const [batchesCache, setBatchesCache] = useState<Record<string, BatchItem[]>>({});
|
|
||||||
const [nextSequences, setNextSequences] = useState<Record<string, number>>({});
|
const [nextSequences, setNextSequences] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
// Fetch batches and sequence for a product
|
// Fetch batches and sequence for a product
|
||||||
const fetchProductBatches = async (productId: number, country: string = 'TW', dateStr: string = '') => {
|
const fetchProductBatches = async (productId: number, country: string = 'TW', dateStr: string = '') => {
|
||||||
if (!data.warehouse_id) return;
|
if (!data.warehouse_id) return;
|
||||||
const cacheKey = `${productId}-${data.warehouse_id}`;
|
// const cacheKey = `${productId}-${data.warehouse_id}`; // Unused
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
@@ -233,13 +226,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
// Update existing batches list
|
// Remove unused batch cache update
|
||||||
if (response.data.batches) {
|
|
||||||
setBatchesCache(prev => ({
|
|
||||||
...prev,
|
|
||||||
[cacheKey]: response.data.batches
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update next sequence for new batch generation
|
// Update next sequence for new batch generation
|
||||||
if (response.data.nextSequence !== undefined) {
|
if (response.data.nextSequence !== undefined) {
|
||||||
@@ -645,11 +632,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="1"
|
step="any"
|
||||||
min="0"
|
min="0"
|
||||||
value={item.quantity_received}
|
value={item.quantity_received}
|
||||||
onChange={(e) => updateItem(index, 'quantity_received', e.target.value)}
|
onChange={(e) => updateItem(index, 'quantity_received', e.target.value)}
|
||||||
className={`w-full ${(errors as any)[errorKey] ? 'border-red-500' : ''}`}
|
className={`w-full text-right ${errors && (errors as any)[errorKey] ? 'border-red-500' : ''}`}
|
||||||
/>
|
/>
|
||||||
{(errors as any)[errorKey] && (
|
{(errors as any)[errorKey] && (
|
||||||
<p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p>
|
<p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p>
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export default function Show({ order }: any) {
|
|||||||
product_name: inv.product_name,
|
product_name: inv.product_name,
|
||||||
product_code: inv.product_code,
|
product_code: inv.product_code,
|
||||||
batch_number: inv.batch_number,
|
batch_number: inv.batch_number,
|
||||||
|
expiry_date: inv.expiry_date,
|
||||||
unit: inv.unit_name,
|
unit: inv.unit_name,
|
||||||
quantity: 1, // Default 1
|
quantity: 1, // Default 1
|
||||||
max_quantity: inv.quantity, // Max available
|
max_quantity: inv.quantity, // Max available
|
||||||
@@ -371,9 +372,10 @@ export default function Show({ order }: any) {
|
|||||||
onCheckedChange={() => toggleSelectAll()}
|
onCheckedChange={() => toggleSelectAll()}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600">商品代號</TableHead>
|
|
||||||
<TableHead className="font-medium text-grey-600">品名</TableHead>
|
<TableHead className="font-medium text-grey-600">品名</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600">批號</TableHead>
|
<TableHead className="font-medium text-grey-600">批號</TableHead>
|
||||||
|
<TableHead className="font-medium text-grey-600">效期</TableHead>
|
||||||
<TableHead className="text-right font-medium text-grey-600 pr-6">現有庫存</TableHead>
|
<TableHead className="text-right font-medium text-grey-600 pr-6">現有庫存</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -409,9 +411,10 @@ export default function Show({ order }: any) {
|
|||||||
onCheckedChange={() => toggleSelect(key)}
|
onCheckedChange={() => toggleSelect(key)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-sm text-grey-1">{inv.product_code}</TableCell>
|
|
||||||
<TableCell className="font-semibold text-grey-0">{inv.product_name}</TableCell>
|
<TableCell className="font-semibold text-grey-0">{inv.product_name}</TableCell>
|
||||||
<TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell>
|
<TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono text-grey-2">{inv.expiry_date || '-'}</TableCell>
|
||||||
<TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
|
<TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
@@ -493,7 +496,14 @@ export default function Show({ order }: any) {
|
|||||||
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
|
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
|
<TableCell className="text-sm font-mono">
|
||||||
|
<div>{item.batch_number || '-'}</div>
|
||||||
|
{item.expiry_date && (
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
效期: {item.expiry_date}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right font-semibold text-primary-main">
|
<TableCell className="text-right font-semibold text-primary-main">
|
||||||
{item.max_quantity} {item.unit || item.unit_name}
|
{item.max_quantity} {item.unit || item.unit_name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -505,10 +515,10 @@ export default function Show({ order }: any) {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.01"
|
min="0.01"
|
||||||
step="0.01"
|
step="any"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)}
|
onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)}
|
||||||
className="h-9 w-32 text-right font-medium focus:ring-primary-main"
|
className="h-9 w-32 font-medium focus:ring-primary-main text-right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -528,7 +538,7 @@ export default function Show({ order }: any) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 p-0" onClick={() => handleRemoveItem(index)}>
|
<Button variant="outline" size="icon" className="button-outlined-error h-8 w-8" onClick={() => handleRemoveItem(index)}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export interface Product {
|
|||||||
location?: string;
|
location?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
cost_price?: number;
|
||||||
|
price?: number;
|
||||||
|
member_price?: number;
|
||||||
|
wholesale_price?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
|
|||||||
@@ -498,7 +498,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
<Label className="text-xs font-medium text-grey-2">生產數量 *</Label>
|
<Label className="text-xs font-medium text-grey-2">生產數量 *</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="any"
|
||||||
value={data.output_quantity}
|
value={data.output_quantity}
|
||||||
onChange={(e) => setData('output_quantity', e.target.value)}
|
onChange={(e) => setData('output_quantity', e.target.value)}
|
||||||
placeholder="例如: 50"
|
placeholder="例如: 50"
|
||||||
@@ -691,11 +691,11 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="1"
|
step="any"
|
||||||
value={item.ui_input_quantity}
|
value={item.ui_input_quantity}
|
||||||
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
|
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
className="h-9"
|
className="h-9 text-right"
|
||||||
disabled={!item.inventory_id}
|
disabled={!item.inventory_id}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
<Label className="text-xs font-medium text-grey-2">生產數量 *</Label>
|
<Label className="text-xs font-medium text-grey-2">生產數量 *</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="any"
|
||||||
value={data.output_quantity}
|
value={data.output_quantity}
|
||||||
onChange={(e) => setData('output_quantity', e.target.value)}
|
onChange={(e) => setData('output_quantity', e.target.value)}
|
||||||
placeholder="例如: 50"
|
placeholder="例如: 50"
|
||||||
@@ -671,11 +671,11 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="1"
|
step="any"
|
||||||
value={item.ui_input_quantity}
|
value={item.ui_input_quantity}
|
||||||
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
|
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
className="h-9"
|
className="h-9 text-right"
|
||||||
disabled={!item.inventory_id}
|
disabled={!item.inventory_id}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export default function RecipeCreate({ products, units }: Props) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
step="any"
|
||||||
value={data.yield_quantity}
|
value={data.yield_quantity}
|
||||||
onChange={(e) => setData('yield_quantity', e.target.value)}
|
onChange={(e) => setData('yield_quantity', e.target.value)}
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
@@ -264,10 +265,11 @@ export default function RecipeCreate({ products, units }: Props) {
|
|||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.0001"
|
step="any"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
||||||
placeholder="數量"
|
placeholder="數量"
|
||||||
|
className="text-right"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="align-middle">
|
<TableCell className="align-middle">
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
step="any"
|
||||||
value={data.yield_quantity}
|
value={data.yield_quantity}
|
||||||
onChange={(e) => setData('yield_quantity', e.target.value)}
|
onChange={(e) => setData('yield_quantity', e.target.value)}
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
@@ -289,10 +290,11 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
|||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.0001"
|
step="any"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
||||||
placeholder="數量"
|
placeholder="數量"
|
||||||
|
className="text-right"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="align-middle">
|
<TableCell className="align-middle">
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export default function CreatePurchaseOrder({
|
|||||||
onChange={(e) => setInvoiceAmount(e.target.value)}
|
onChange={(e) => setInvoiceAmount(e.target.value)}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="any"
|
||||||
className="block w-full"
|
className="block w-full"
|
||||||
/>
|
/>
|
||||||
{invoiceAmount && totalAmount > 0 && parseFloat(invoiceAmount) !== totalAmount && (
|
{invoiceAmount && totalAmount > 0 && parseFloat(invoiceAmount) !== totalAmount && (
|
||||||
@@ -419,6 +419,7 @@ export default function CreatePurchaseOrder({
|
|||||||
<div className="relative w-32">
|
<div className="relative w-32">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
step="any"
|
||||||
value={taxAmount}
|
value={taxAmount}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTaxAmount(e.target.value);
|
setTaxAmount(e.target.value);
|
||||||
|
|||||||
@@ -260,8 +260,9 @@ export default function ShippingOrderCreate({ order, warehouses, products }: Pro
|
|||||||
type="number"
|
type="number"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onChange={e => updateItem(index, { quantity: parseFloat(e.target.value) || 0 })}
|
onChange={e => updateItem(index, { quantity: parseFloat(e.target.value) || 0 })}
|
||||||
min={0.0001}
|
min={0}
|
||||||
step={0.0001}
|
step="any"
|
||||||
|
className="text-right"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
@@ -270,6 +271,8 @@ export default function ShippingOrderCreate({ order, warehouses, products }: Pro
|
|||||||
value={item.unit_price}
|
value={item.unit_price}
|
||||||
onChange={e => updateItem(index, { unit_price: parseFloat(e.target.value) || 0 })}
|
onChange={e => updateItem(index, { unit_price: parseFloat(e.target.value) || 0 })}
|
||||||
min={0}
|
min={0}
|
||||||
|
step="any"
|
||||||
|
className="text-right"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 font-medium">
|
<td className="p-2 font-medium">
|
||||||
@@ -308,7 +311,8 @@ export default function ShippingOrderCreate({ order, warehouses, products }: Pro
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={taxAmount}
|
value={taxAmount}
|
||||||
onChange={e => setTaxAmount(parseInt(e.target.value) || 0)}
|
onChange={e => setTaxAmount(parseFloat(e.target.value) || 0)}
|
||||||
|
step="any"
|
||||||
className="h-8 text-right p-1"
|
className="h-8 text-right p-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,14 +23,17 @@ import { Warehouse, InboundItem, InboundReason } from "@/types/warehouse";
|
|||||||
import { getCurrentDateTime } from "@/utils/format";
|
import { getCurrentDateTime } from "@/utils/format";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getInventoryBreadcrumbs } from "@/utils/breadcrumb";
|
import { getInventoryBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
|
import ScannerInput from "@/Components/Inventory/ScannerInput";
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
barcode?: string;
|
||||||
baseUnit: string;
|
baseUnit: string;
|
||||||
largeUnit?: string;
|
largeUnit?: string;
|
||||||
conversionRate?: number;
|
conversionRate?: number;
|
||||||
|
costPrice?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Batch {
|
interface Batch {
|
||||||
@@ -113,9 +116,101 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
});
|
});
|
||||||
}, [items, inboundDate]);
|
}, [items, inboundDate]);
|
||||||
|
|
||||||
|
// 處理掃碼輸入
|
||||||
|
const handleScan = async (code: string, mode: 'continuous' | 'single') => {
|
||||||
|
const cleanCode = code.trim();
|
||||||
|
// 1. 搜尋商品 (優先比對 Code, Barcode, ID)
|
||||||
|
let product = products.find(p => p.code === cleanCode || p.barcode === cleanCode || p.id === cleanCode);
|
||||||
|
|
||||||
|
// 如果前端找不到,嘗試 API 搜尋 (Fallback)
|
||||||
|
if (!product) {
|
||||||
|
try {
|
||||||
|
// 這裡假設有 API 可以搜尋商品,若沒有則會失敗
|
||||||
|
// 使用 Product/Index 的搜尋邏輯 (Inertia Props 比較難已 AJAX 取得)
|
||||||
|
// 替代方案:直接請求 /products?search=CLEAN_CODE&per_page=1
|
||||||
|
// 加上 header 確認是 JSON 請求
|
||||||
|
const response = await fetch(`/products?search=${encodeURIComponent(cleanCode)}&per_page=1`, {
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest', // 強制 AJAX 識別
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Inertia 回傳的是 component props 結構,或 partial props
|
||||||
|
// 根據 ProductController::index,回傳 props.products.data
|
||||||
|
if (data.props && data.props.products && data.props.products.data && data.props.products.data.length > 0) {
|
||||||
|
const foundProduct = data.props.products.data[0];
|
||||||
|
// 轉換格式以符合 AddInventory 的 Product 介面
|
||||||
|
product = {
|
||||||
|
id: foundProduct.id,
|
||||||
|
name: foundProduct.name,
|
||||||
|
code: foundProduct.code,
|
||||||
|
barcode: foundProduct.barcode,
|
||||||
|
baseUnit: foundProduct.baseUnit?.name || '個',
|
||||||
|
largeUnit: foundProduct.largeUnit?.name,
|
||||||
|
conversionRate: foundProduct.conversionRate,
|
||||||
|
costPrice: foundProduct.costPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("API Search failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
toast.error(`找不到商品: ${code}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 連續模式:尋找最近一筆相同商品並 +1
|
||||||
|
if (mode === 'continuous') {
|
||||||
|
let foundIndex = -1;
|
||||||
|
// 從後往前搜尋,找到最近加入的那一筆
|
||||||
|
for (let i = items.length - 1; i >= 0; i--) {
|
||||||
|
if (items[i].productId === product.id) {
|
||||||
|
foundIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundIndex !== -1) {
|
||||||
|
// 更新數量
|
||||||
|
const newItems = [...items];
|
||||||
|
const currentQty = newItems[foundIndex].quantity || 0;
|
||||||
|
newItems[foundIndex] = {
|
||||||
|
...newItems[foundIndex],
|
||||||
|
quantity: currentQty + 1
|
||||||
|
};
|
||||||
|
setItems(newItems);
|
||||||
|
toast.success(`${product.name} 數量 +1 (總數: ${currentQty + 1})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 單筆模式 或 連續模式但尚未加入過:新增一筆
|
||||||
|
const newItem: InboundItem = {
|
||||||
|
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
productId: product.id,
|
||||||
|
productName: product.name,
|
||||||
|
quantity: 1,
|
||||||
|
unit: product.baseUnit, // 僅用於顯示當前選擇單位的名稱
|
||||||
|
baseUnit: product.baseUnit,
|
||||||
|
largeUnit: product.largeUnit,
|
||||||
|
conversionRate: product.conversionRate,
|
||||||
|
selectedUnit: 'base',
|
||||||
|
batchMode: 'existing', // 預設選擇現有批號 (需要使用者確認/輸入)
|
||||||
|
originCountry: 'TW',
|
||||||
|
unit_cost: product.costPrice || 0,
|
||||||
|
};
|
||||||
|
setItems(prev => [...prev, newItem]);
|
||||||
|
toast.success(`已加入 ${product.name}`);
|
||||||
|
};
|
||||||
|
|
||||||
// 新增明細行
|
// 新增明細行
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", code: "", baseUnit: "個" };
|
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", code: "", baseUnit: "個", costPrice: 0 };
|
||||||
const newItem: InboundItem = {
|
const newItem: InboundItem = {
|
||||||
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
productId: defaultProduct.id,
|
productId: defaultProduct.id,
|
||||||
@@ -128,6 +223,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
selectedUnit: 'base',
|
selectedUnit: 'base',
|
||||||
batchMode: 'existing', // 預設選擇現有批號
|
batchMode: 'existing', // 預設選擇現有批號
|
||||||
originCountry: 'TW',
|
originCountry: 'TW',
|
||||||
|
unit_cost: defaultProduct.costPrice || 0,
|
||||||
};
|
};
|
||||||
setItems([...items, newItem]);
|
setItems([...items, newItem]);
|
||||||
};
|
};
|
||||||
@@ -162,6 +258,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
batchMode: 'existing',
|
batchMode: 'existing',
|
||||||
inventoryId: undefined, // 清除已選擇的批號
|
inventoryId: undefined, // 清除已選擇的批號
|
||||||
expiryDate: undefined,
|
expiryDate: undefined,
|
||||||
|
unit_cost: product.costPrice || 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -224,7 +321,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
batchMode: item.batchMode,
|
batchMode: item.batchMode,
|
||||||
inventoryId: item.inventoryId,
|
inventoryId: item.inventoryId,
|
||||||
originCountry: item.originCountry,
|
originCountry: item.originCountry,
|
||||||
expiryDate: item.expiryDate
|
expiryDate: item.expiryDate,
|
||||||
|
unit_cost: item.unit_cost
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
}, {
|
}, {
|
||||||
@@ -384,6 +482,12 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 掃碼輸入區 */}
|
||||||
|
<ScannerInput
|
||||||
|
onScan={handleScan}
|
||||||
|
className="bg-gray-50/50"
|
||||||
|
/>
|
||||||
|
|
||||||
{errors.items && (
|
{errors.items && (
|
||||||
<p className="text-sm text-red-500">{errors.items}</p>
|
<p className="text-sm text-red-500">{errors.items}</p>
|
||||||
)}
|
)}
|
||||||
@@ -399,12 +503,13 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
<TableHead className="w-[220px]">
|
<TableHead className="w-[220px]">
|
||||||
批號 <span className="text-red-500">*</span>
|
批號 <span className="text-red-500">*</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="w-[100px]">
|
||||||
|
單價
|
||||||
|
</TableHead>
|
||||||
<TableHead className="w-[100px]">
|
<TableHead className="w-[100px]">
|
||||||
數量 <span className="text-red-500">*</span>
|
數量 <span className="text-red-500">*</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[90px]">單位</TableHead>
|
<TableHead className="w-[90px]">單位</TableHead>
|
||||||
<TableHead className="w-[100px]">轉換數量</TableHead>
|
|
||||||
<TableHead className="w-[140px]">效期</TableHead>
|
|
||||||
<TableHead className="w-[50px]"></TableHead>
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -479,6 +584,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{item.batchMode === 'new' && (
|
{item.batchMode === 'new' && (
|
||||||
|
<>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input
|
<Input
|
||||||
@@ -496,29 +602,72 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
{getBatchPreview(item.productId, product?.code, item.originCountry || 'TW', inboundDate)}
|
{getBatchPreview(item.productId, product?.code, item.originCountry || 'TW', inboundDate)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 新增效期輸入 (在新增批號模式下) */}
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500 whitespace-nowrap">效期:</span>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Calendar className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-gray-400 pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={item.expiryDate || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateItem(item.tempId, {
|
||||||
|
expiryDate: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-8 pl-8 text-xs border-gray-300 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.batchMode === 'existing' && item.inventoryId && (
|
{item.batchMode === 'existing' && item.inventoryId && (
|
||||||
<div className="text-xs text-gray-500 px-2 font-mono">
|
<div className="flex flax-col gap-1 mt-1">
|
||||||
|
<div className="text-xs text-gray-500 font-mono">
|
||||||
效期: {item.expiryDate || '未設定'}
|
效期: {item.expiryDate || '未設定'}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 單價 */}
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={item.unit_cost || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateItem(item.tempId, {
|
||||||
|
unit_cost: parseFloat(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="border-gray-300 bg-gray-50 text-right"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
{/* 數量 */}
|
{/* 數量 */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
step="any"
|
||||||
value={item.quantity || ""}
|
value={item.quantity || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleUpdateItem(item.tempId, {
|
handleUpdateItem(item.tempId, {
|
||||||
quantity: parseFloat(e.target.value) || 0,
|
quantity: parseFloat(e.target.value) || 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="border-gray-300"
|
className="border-gray-300 text-right"
|
||||||
/>
|
/>
|
||||||
|
{item.selectedUnit === 'large' && item.conversionRate && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
轉換: {convertedQuantity} {item.baseUnit || "個"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{errors[`item-${index}-quantity`] && (
|
{errors[`item-${index}-quantity`] && (
|
||||||
<p className="text-xs text-red-500 mt-1">
|
<p className="text-xs text-red-500 mt-1">
|
||||||
{errors[`item-${index}-quantity`]}
|
{errors[`item-${index}-quantity`]}
|
||||||
@@ -544,48 +693,20 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
className="border-gray-300"
|
className="border-gray-300"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<div className="text-sm text-gray-700 font-medium px-3 py-2 bg-gray-50 border border-gray-200 rounded-md">
|
||||||
value={item.baseUnit || "個"}
|
{item.baseUnit || "個"}
|
||||||
disabled
|
</div>
|
||||||
className="bg-gray-50 border-gray-200"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* 轉換數量 */}
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center text-gray-700 font-medium bg-gray-50 px-3 py-2 rounded-md border border-gray-200">
|
|
||||||
<span>{convertedQuantity}</span>
|
|
||||||
<span className="ml-1 text-gray-500 text-sm">{item.baseUnit || "個"}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* 效期 */}
|
|
||||||
<TableCell>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={item.expiryDate || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleUpdateItem(item.tempId, {
|
|
||||||
expiryDate: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={item.batchMode === 'existing'}
|
|
||||||
className={`border-gray-300 pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* 刪除按鈕 */}
|
{/* 刪除按鈕 */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveItem(item.tempId)}
|
onClick={() => handleRemoveItem(item.tempId)}
|
||||||
className="hover:bg-red-50 hover:text-red-600 h-8 w-8"
|
className="button-outlined-error h-8 w-8"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -605,6 +726,6 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export default function EditInventory({ warehouse, inventory, transactions = []
|
|||||||
id="quantity"
|
id="quantity"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="any"
|
||||||
value={data.quantity}
|
value={data.quantity}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setData("quantity", parseFloat(e.target.value) || 0)
|
setData("quantity", parseFloat(e.target.value) || 0)
|
||||||
|
|||||||
@@ -101,16 +101,7 @@ export default function WarehouseInventoryPage({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdjust = (batchId: string, data: { operation: string; quantity: number; reason: string }) => {
|
|
||||||
router.put(route("warehouses.inventory.update", { warehouse: warehouse.id, inventoryId: batchId }), data, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("庫存已更新");
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("庫存更新失敗");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout breadcrumbs={getInventoryBreadcrumbs(warehouse.id, warehouse.name)}>
|
<AuthenticatedLayout breadcrumbs={getInventoryBreadcrumbs(warehouse.id, warehouse.name)}>
|
||||||
@@ -195,7 +186,6 @@ export default function WarehouseInventoryPage({
|
|||||||
inventories={filteredInventories}
|
inventories={filteredInventories}
|
||||||
onView={handleView}
|
onView={handleView}
|
||||||
onDelete={confirmDelete}
|
onDelete={confirmDelete}
|
||||||
onAdjust={handleAdjust}
|
|
||||||
onViewProduct={handleViewProduct}
|
onViewProduct={handleViewProduct}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user