feat: enhance inventory management with new item types, stocktake, wastage, and recipe-based deductions
This commit is contained in:
@@ -22,9 +22,78 @@
|
||||
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(239,68,68,0.1);"><i data-lucide="x-circle" style="color:#EF4444;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_inventory.Count(i => i.Quantity <= 0)</span><span class="admin-stat-card__label">Hết hàng</span></div></div>
|
||||
</div>
|
||||
|
||||
<!-- EN: Action bar — add new inventory item button / VI: Thanh hành động — nút thêm nguyên liệu mới -->
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:16px;">
|
||||
<button @onclick="@(() => { _showCreateForm = !_showCreateForm; })"
|
||||
style="padding:8px 16px;background:var(--admin-orange-primary);color:#FFF;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:6px;">
|
||||
<i data-lucide="plus" style="width:14px;height:14px;"></i> Thêm nguyên liệu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_showCreateForm)
|
||||
{
|
||||
<!-- EN: Create new inventory item form / VI: Form tạo mặt hàng tồn kho mới -->
|
||||
<div class="admin-panel" style="margin-top:12px;">
|
||||
<div class="admin-panel__header">
|
||||
<h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="package-plus" style="width:18px;height:18px;margin-right:8px;color:var(--admin-orange-primary);"></i>Thêm nguyên liệu / vật tư</h3>
|
||||
<button @onclick="@(() => _showCreateForm = false)" style="background:transparent;border:none;cursor:pointer;color:var(--admin-text-tertiary);font-size:18px;">✕</button>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;max-width:700px;">
|
||||
<div style="grid-column:span 2;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Tên nguyên liệu *</label>
|
||||
<input type="text" @bind="_createName" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="VD: Cà phê rang xay, Sữa tươi..." />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Loại *</label>
|
||||
<select @bind="_createItemTypeId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
|
||||
<option value="1">Nguyên liệu</option>
|
||||
<option value="2">Thành phẩm</option>
|
||||
<option value="3">Vật tư tiêu hao</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Đơn vị *</label>
|
||||
<select @bind="_createUnit" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
|
||||
<option value="g">g (gram)</option>
|
||||
<option value="ml">ml (mililít)</option>
|
||||
<option value="kg">kg (kilogram)</option>
|
||||
<option value="L">L (lít)</option>
|
||||
<option value="cái">cái</option>
|
||||
<option value="gói">gói</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Giá nhập / đơn vị (VND) *</label>
|
||||
<input type="number" @bind="_createCostPerUnit" min="0" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng ban đầu</label>
|
||||
<input type="number" @bind="_createInitialQty" min="0" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Mức nhập lại</label>
|
||||
<input type="number" @bind="_createReorderLevel" min="0" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="10" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Nhà cung cấp</label>
|
||||
<input type="text" @bind="_createSupplier" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Tên nhà cung cấp..." />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Hạn sử dụng</label>
|
||||
<input type="date" @bind="_createExpiryDate" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" />
|
||||
</div>
|
||||
<div style="grid-column:span 2;">
|
||||
<button @onclick="DoCreateInventoryItem" style="padding:10px 24px;background:var(--admin-orange-primary);color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
<i data-lucide="plus" style="width:16px;height:16px;margin-right:6px;"></i>Tạo nguyên liệu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Sub-tabs -->
|
||||
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;margin-top:16px;">
|
||||
@foreach (var (label, val, icon) in new[] { ("Tồn kho", "levels", "package"), ("Nhập kho", "stock-in", "arrow-down-to-line"), ("Xuất kho", "stock-out", "arrow-up-from-line"), ("Điều chỉnh", "adjust", "settings-2"), ("Lịch sử", "transactions", "history"), ("Cảnh báo", "low-stock", "alert-triangle") })
|
||||
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;margin-top:16px;flex-wrap:wrap;">
|
||||
@foreach (var (label, val, icon) in new[] { ("Tồn kho", "levels", "package"), ("Nhập kho", "stock-in", "arrow-down-to-line"), ("Xuất kho", "stock-out", "arrow-up-from-line"), ("Điều chỉnh", "adjust", "settings-2"), ("Hao hụt", "wastage", "trash-2"), ("Kiểm kê", "stocktake", "clipboard-check"), ("Lịch sử", "transactions", "history"), ("Cảnh báo", "low-stock", "alert-triangle") })
|
||||
{
|
||||
<button @onclick="@(() => SwitchInvSubTab(val))"
|
||||
style="padding:8px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;border:none;display:flex;align-items:center;gap:6px;
|
||||
@@ -45,10 +114,12 @@
|
||||
{
|
||||
case "levels":
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
<div class="admin-panel__body" style="padding:0;overflow-x:auto;">
|
||||
<table class="admin-table" style="width:100%;"><thead><tr>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Sản phẩm</th>
|
||||
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Loại</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số lượng</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Giá nhập</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mức nhập lại</th>
|
||||
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thao tác</th>
|
||||
</tr></thead><tbody>
|
||||
@@ -56,9 +127,23 @@
|
||||
{
|
||||
var qtyColor = item.Quantity <= 0 ? "#EF4444" : item.Quantity <= (item.ReorderLevel > 0 ? item.ReorderLevel : 10) ? "#F59E0B" : "#22C55E";
|
||||
var bgColor = item.Quantity <= 0 ? "rgba(239,68,68,0.05)" : item.Quantity <= (item.ReorderLevel > 0 ? item.ReorderLevel : 10) ? "rgba(245,158,11,0.05)" : "transparent";
|
||||
// EN: ItemType badge colors — RawMaterial=green, FinishedProduct=blue, Supply=gray
|
||||
// VI: Màu badge loại — Nguyên liệu=xanh lá, Thành phẩm=xanh dương, Vật tư=xám
|
||||
var (typeBadge, typeBg, typeColor) = item.ItemType switch
|
||||
{
|
||||
"RawMaterial" => ("Nguyên liệu", "rgba(34,197,94,0.1)", "#16A34A"),
|
||||
"FinishedProduct" => ("Thành phẩm", "rgba(59,130,246,0.1)", "#3B82F6"),
|
||||
"Supply" => ("Vật tư", "rgba(107,114,128,0.1)", "#6B7280"),
|
||||
_ => ("—", "transparent", "var(--admin-text-tertiary)")
|
||||
};
|
||||
var unitLabel = item.Unit ?? "";
|
||||
<tr style="border-top:1px solid var(--admin-border-subtle);background:@bgColor;">
|
||||
<td style="padding:12px 16px;font-weight:600;">@(item.ProductName ?? item.ProductId.ToString()[..8])</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@qtyColor;">@item.Quantity</td>
|
||||
<td style="padding:12px 16px;text-align:center;">
|
||||
<span style="padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:@typeBg;color:@typeColor;">@typeBadge</span>
|
||||
</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@qtyColor;">@item.Quantity @unitLabel</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-secondary);">@(item.CostPerUnit.HasValue ? item.CostPerUnit.Value.ToString("#,0") + "đ" : "—")</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-tertiary);">@item.ReorderLevel</td>
|
||||
<td style="padding:12px 16px;text-align:center;">
|
||||
<div style="display:flex;gap:4px;justify-content:center;">
|
||||
@@ -66,6 +151,9 @@
|
||||
style="background:rgba(34,197,94,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;color:#16A34A;cursor:pointer;">+Nhập</button>
|
||||
<button @onclick="@(() => { _invSubTab = "stock-out"; _invSelectedProductId = item.ProductId; _invAmount = 0; _invNotes = ""; StateHasChanged(); })"
|
||||
style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;color:#DC2626;cursor:pointer;">-Xuất</button>
|
||||
<button @onclick="@(() => DoDeleteInventoryItem(item.Id))"
|
||||
style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;color:#DC2626;cursor:pointer;"
|
||||
title="Xóa nguyên liệu">🗑</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -78,14 +166,14 @@
|
||||
case "stock-in":
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="arrow-down-to-line" style="width:18px;height:18px;margin-right:8px;color:#22C55E;"></i>Nhập kho</h3></div>
|
||||
<div class="admin-panel__body" style="display:grid;gap:16px;max-width:500px;">
|
||||
<div>
|
||||
<div class="admin-panel__body" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;max-width:700px;">
|
||||
<div style="grid-column:span 2;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Sản phẩm *</label>
|
||||
<select @bind="_invSelectedProductId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
|
||||
<option value="@Guid.Empty">-- Chọn sản phẩm --</option>
|
||||
@foreach (var item in _inventory)
|
||||
{
|
||||
<option value="@item.ProductId">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity)</option>
|
||||
<option value="@item.ProductId">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity @(item.Unit ?? ""))</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@@ -93,13 +181,32 @@
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng nhập *</label>
|
||||
<input type="number" @bind="_invAmount" min="1" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Nhập số lượng..." />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Giá nhập / đơn vị (VND)</label>
|
||||
<input type="number" @bind="_invStockInUnitCost" min="0" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Giá nhập lần này..." />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Nhà cung cấp</label>
|
||||
<input type="text" @bind="_invStockInSupplier" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Tên nhà cung cấp..." />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Ghi chú</label>
|
||||
<input type="text" @bind="_invNotes" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Lý do nhập kho..." />
|
||||
</div>
|
||||
<button @onclick="DoStockIn" style="padding:10px 20px;background:#22C55E;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
<i data-lucide="arrow-down-to-line" style="width:16px;height:16px;margin-right:6px;"></i>Nhập kho
|
||||
</button>
|
||||
<!-- EN: Invoice image upload placeholder / VI: Placeholder tải hóa đơn -->
|
||||
<div style="grid-column:span 2;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Tải hóa đơn</label>
|
||||
<div style="border:2px dashed var(--admin-border-subtle);border-radius:8px;padding:24px;text-align:center;cursor:pointer;background:var(--admin-bg-elevated);">
|
||||
<i data-lucide="upload" style="width:28px;height:28px;color:var(--admin-text-tertiary);margin-bottom:8px;"></i>
|
||||
<p style="margin:0;font-size:13px;color:var(--admin-text-tertiary);">Kéo thả hoặc click để tải ảnh hóa đơn</p>
|
||||
<p style="margin:4px 0 0;font-size:11px;color:var(--admin-text-tertiary);opacity:0.6;">PNG, JPG — Tối đa 5MB (sắp ra mắt)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="grid-column:span 2;">
|
||||
<button @onclick="DoStockIn" style="padding:10px 20px;background:#22C55E;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
<i data-lucide="arrow-down-to-line" style="width:16px;height:16px;margin-right:6px;"></i>Nhập kho
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
@@ -162,6 +269,112 @@
|
||||
</div>
|
||||
break;
|
||||
|
||||
case "wastage":
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="trash-2" style="width:18px;height:18px;margin-right:8px;color:#EF4444;"></i>Ghi nhận hao hụt</h3></div>
|
||||
<div class="admin-panel__body" style="display:grid;gap:16px;max-width:500px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Nguyên liệu *</label>
|
||||
<select @bind="_wastageSelectedItemId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
|
||||
<option value="@Guid.Empty">-- Chọn nguyên liệu --</option>
|
||||
@foreach (var item in _inventory)
|
||||
{
|
||||
<option value="@item.Id">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity @(item.Unit ?? ""))</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng hao hụt *</label>
|
||||
<input type="number" @bind="_wastageAmount" min="1" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Nhập số lượng..." />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Lý do *</label>
|
||||
<select @bind="_wastageReason" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
|
||||
<option value="">-- Chọn lý do --</option>
|
||||
<option value="Hết hạn">Hết hạn</option>
|
||||
<option value="Hư hỏng">Hư hỏng</option>
|
||||
<option value="Đổ/tràn">Đổ/tràn</option>
|
||||
<option value="Khác">Khác</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Ghi chú</label>
|
||||
<input type="text" @bind="_wastageNotes" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Chi tiết thêm..." />
|
||||
</div>
|
||||
<button @onclick="DoRecordWastage" style="padding:10px 20px;background:#EF4444;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
<i data-lucide="trash-2" style="width:16px;height:16px;margin-right:6px;"></i>Ghi nhận hao hụt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case "stocktake":
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="clipboard-check" style="width:18px;height:18px;margin-right:8px;color:#8B5CF6;"></i>Kiểm kê tồn kho</h3></div>
|
||||
<div class="admin-panel__body" style="padding:0;overflow-x:auto;">
|
||||
<table class="admin-table" style="width:100%;"><thead><tr>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Nguyên liệu</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tồn hệ thống</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Đếm thực tế</th>
|
||||
</tr></thead><tbody>
|
||||
@foreach (var item in _inventory)
|
||||
{
|
||||
var itemId = item.Id;
|
||||
<tr style="border-top:1px solid var(--admin-border-subtle);">
|
||||
<td style="padding:12px 16px;font-weight:600;">@(item.ProductName ?? item.ProductId.ToString()[..8]) <span style="font-size:11px;color:var(--admin-text-tertiary);">@(item.Unit ?? "")</span></td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:700;color:var(--admin-text-secondary);">@item.Quantity</td>
|
||||
<td style="padding:12px 16px;text-align:right;">
|
||||
<input type="number" min="0" value="@(GetStocktakeCount(itemId))"
|
||||
@onchange="@(e => SetStocktakeCount(itemId, int.TryParse(e.Value?.ToString(), out var v) ? v : 0))"
|
||||
style="width:100px;padding:6px 10px;border:1px solid var(--admin-border-subtle);border-radius:6px;font-size:14px;text-align:right;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody></table>
|
||||
<div style="padding:16px;display:flex;gap:12px;align-items:center;">
|
||||
<button @onclick="DoStocktake" style="padding:10px 20px;background:#8B5CF6;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
<i data-lucide="clipboard-check" style="width:16px;height:16px;margin-right:6px;"></i>Xác nhận kiểm kê
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (_stocktakeResult != null)
|
||||
{
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="bar-chart-3" style="width:18px;height:18px;margin-right:8px;color:#F59E0B;"></i>Kết quả kiểm kê (@_stocktakeResult.TotalItemsCounted mặt hàng)</h3></div>
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
@if (_stocktakeResult.Discrepancies.Any())
|
||||
{
|
||||
<table class="admin-table" style="width:100%;"><thead><tr>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Nguyên liệu</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Hệ thống</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thực tế</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Chênh lệch</th>
|
||||
</tr></thead><tbody>
|
||||
@foreach (var d in _stocktakeResult.Discrepancies)
|
||||
{
|
||||
var diffColor = d.Difference > 0 ? "#22C55E" : "#EF4444";
|
||||
<tr style="border-top:1px solid var(--admin-border-subtle);">
|
||||
<td style="padding:12px 16px;font-weight:600;">@(d.ItemName ?? d.InventoryItemId.ToString()[..8])</td>
|
||||
<td style="padding:12px 16px;text-align:right;color:var(--admin-text-secondary);">@d.ExpectedQuantity</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:700;">@d.CountedQuantity</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@diffColor;">@(d.Difference > 0 ? "+" : "")@d.Difference</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody></table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="text-align:center;padding:32px;color:var(--admin-text-tertiary);font-size:14px;">
|
||||
<i data-lucide="check-circle" style="width:32px;height:32px;color:#22C55E;margin-bottom:8px;"></i><br/>
|
||||
Không có chênh lệch. Tồn kho chính xác!
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
break;
|
||||
|
||||
case "transactions":
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="history" style="width:18px;height:18px;margin-right:8px;color:#8B5CF6;"></i>Lịch sử giao dịch kho</h3></div>
|
||||
@@ -177,7 +390,7 @@
|
||||
@foreach (var tx in _invTxns.OrderByDescending(t => t.CreatedAt).Take(50))
|
||||
{
|
||||
var txColor = tx.QuantityChange > 0 ? "#22C55E" : tx.QuantityChange < 0 ? "#EF4444" : "#6B7280";
|
||||
var txLabel = tx.TransactionType switch { "StockIn" => "Nhập kho", "StockOut" => "Xuất kho", "Adjustment" => "Điều chỉnh", "OrderDeduction" => "Đơn hàng", _ => tx.TransactionType ?? "N/A" };
|
||||
var txLabel = tx.TransactionType switch { "StockIn" => "Nhập kho", "StockOut" => "Xuất kho", "Adjustment" => "Điều chỉnh", "OrderDeduction" => "Đơn hàng", "Wastage" => "Hao hụt", _ => tx.TransactionType ?? "N/A" };
|
||||
<tr style="border-top:1px solid var(--admin-border-subtle);">
|
||||
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-secondary);">@tx.CreatedAt.ToLocalTime().ToString("dd/MM HH:mm")</td>
|
||||
<td style="padding:12px 16px;">
|
||||
@@ -260,6 +473,31 @@
|
||||
private bool _invFormSuccess;
|
||||
private List<PosDataService.LowStockItemInfo> _lowStockItems = new();
|
||||
|
||||
// EN: Create inventory item form state / VI: State form tạo nguyên liệu
|
||||
private bool _showCreateForm;
|
||||
private string _createName = "";
|
||||
private int _createItemTypeId = 1;
|
||||
private string _createUnit = "g";
|
||||
private decimal _createCostPerUnit;
|
||||
private int _createInitialQty;
|
||||
private int _createReorderLevel = 10;
|
||||
private string _createSupplier = "";
|
||||
private DateTime? _createExpiryDate;
|
||||
|
||||
// EN: Stock-in extra fields / VI: Trường bổ sung cho nhập kho
|
||||
private decimal? _invStockInUnitCost;
|
||||
private string _invStockInSupplier = "";
|
||||
|
||||
// EN: Wastage form state / VI: State form hao hụt
|
||||
private Guid _wastageSelectedItemId;
|
||||
private int _wastageAmount;
|
||||
private string _wastageReason = "";
|
||||
private string _wastageNotes = "";
|
||||
|
||||
// EN: Stocktake state / VI: State kiểm kê
|
||||
private Dictionary<Guid, int> _stocktakeCounts = new();
|
||||
private PosDataService.StocktakeResult? _stocktakeResult;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_inventory = await DataService.GetInventoryAsync(_shopGuid);
|
||||
@@ -284,10 +522,14 @@
|
||||
{
|
||||
_invFormMessage = null;
|
||||
if (_invSelectedProductId == Guid.Empty || _invAmount <= 0) { _invFormMessage = "Vui lòng chọn sản phẩm và nhập số lượng > 0."; _invFormSuccess = false; return; }
|
||||
var ok = await DataService.StockInAsync(new PosDataService.StockInRequest(_invSelectedProductId, ShopId, _invAmount, string.IsNullOrWhiteSpace(_invNotes) ? null : _invNotes));
|
||||
var supplier = string.IsNullOrWhiteSpace(_invStockInSupplier) ? null : _invStockInSupplier;
|
||||
var ok = await DataService.StockInAsync(new PosDataService.StockInRequest(
|
||||
_invSelectedProductId, ShopId, _invAmount,
|
||||
string.IsNullOrWhiteSpace(_invNotes) ? null : _invNotes,
|
||||
null, _invStockInUnitCost, supplier));
|
||||
_invFormMessage = ok ? $"Đã nhập kho thành công +{_invAmount}!" : "Lỗi khi nhập kho.";
|
||||
_invFormSuccess = ok;
|
||||
if (ok) { _invAmount = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
|
||||
if (ok) { _invAmount = 0; _invNotes = ""; _invStockInUnitCost = null; _invStockInSupplier = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
|
||||
}
|
||||
|
||||
private async Task DoStockOut()
|
||||
@@ -309,4 +551,97 @@
|
||||
_invFormSuccess = ok;
|
||||
if (ok) { _invNewQty = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
|
||||
}
|
||||
|
||||
private async Task DoRecordWastage()
|
||||
{
|
||||
_invFormMessage = null;
|
||||
if (_wastageSelectedItemId == Guid.Empty || _wastageAmount <= 0 || string.IsNullOrWhiteSpace(_wastageReason))
|
||||
{
|
||||
_invFormMessage = "Vui lòng chọn nguyên liệu, nhập số lượng > 0 và chọn lý do."; _invFormSuccess = false; return;
|
||||
}
|
||||
var ok = await DataService.RecordWastageAsync(new PosDataService.RecordWastageRequest(
|
||||
_wastageSelectedItemId, _wastageAmount, _wastageReason,
|
||||
string.IsNullOrWhiteSpace(_wastageNotes) ? null : _wastageNotes));
|
||||
_invFormMessage = ok ? $"Đã ghi nhận hao hụt -{_wastageAmount}!" : "Lỗi khi ghi nhận hao hụt.";
|
||||
_invFormSuccess = ok;
|
||||
if (ok) { _wastageAmount = 0; _wastageReason = ""; _wastageNotes = ""; _wastageSelectedItemId = Guid.Empty; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
|
||||
}
|
||||
|
||||
private int GetStocktakeCount(Guid itemId) => _stocktakeCounts.TryGetValue(itemId, out var v) ? v : 0;
|
||||
|
||||
private void SetStocktakeCount(Guid itemId, int value) => _stocktakeCounts[itemId] = value;
|
||||
|
||||
private async Task DoStocktake()
|
||||
{
|
||||
_invFormMessage = null;
|
||||
_stocktakeResult = null;
|
||||
var items = _inventory
|
||||
.Where(i => _stocktakeCounts.ContainsKey(i.Id))
|
||||
.Select(i => new PosDataService.StocktakeItemRequest(i.Id, _stocktakeCounts[i.Id]))
|
||||
.ToList();
|
||||
if (!items.Any()) { _invFormMessage = "Vui lòng nhập số lượng đếm cho ít nhất 1 mặt hàng."; _invFormSuccess = false; return; }
|
||||
var result = await DataService.StocktakeAsync(new PosDataService.StocktakeRequest(ShopId, items));
|
||||
if (result != null)
|
||||
{
|
||||
_stocktakeResult = result;
|
||||
_invFormMessage = $"Kiểm kê hoàn thành: {result.TotalItemsCounted} mặt hàng, {result.Discrepancies.Count} chênh lệch.";
|
||||
_invFormSuccess = true;
|
||||
_stocktakeCounts.Clear();
|
||||
_inventory = await DataService.GetInventoryAsync(_shopGuid);
|
||||
_invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid);
|
||||
}
|
||||
else { _invFormMessage = "Lỗi khi thực hiện kiểm kê."; _invFormSuccess = false; }
|
||||
}
|
||||
|
||||
private async Task DoDeleteInventoryItem(Guid itemId)
|
||||
{
|
||||
var item = _inventory.FirstOrDefault(i => i.Id == itemId);
|
||||
var name = item?.ProductName ?? "nguyên liệu";
|
||||
// Simple confirmation by checking quantity
|
||||
if (item != null && item.Quantity > 0)
|
||||
{
|
||||
_invFormMessage = $"⚠️ Không thể xóa '{name}' vì còn {item.Quantity} {item.Unit} trong kho. Hãy xuất hết trước.";
|
||||
_invFormSuccess = false;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
var ok = await DataService.DeleteInventoryItemAsync(itemId);
|
||||
if (ok)
|
||||
{
|
||||
_invFormMessage = $"✅ Đã xóa '{name}' khỏi tồn kho!";
|
||||
_invFormSuccess = true;
|
||||
_inventory = await DataService.GetInventoryAsync(_shopGuid);
|
||||
}
|
||||
else
|
||||
{
|
||||
_invFormMessage = $"❌ Không thể xóa '{name}'.";
|
||||
_invFormSuccess = false;
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// EN: Create a new inventory item (raw material / supply) / VI: Tạo nguyên liệu / vật tư mới
|
||||
private async Task DoCreateInventoryItem()
|
||||
{
|
||||
_invFormMessage = null;
|
||||
if (string.IsNullOrWhiteSpace(_createName))
|
||||
{
|
||||
_invFormMessage = "Vui lòng nhập tên nguyên liệu."; _invFormSuccess = false; return;
|
||||
}
|
||||
var req = new PosDataService.CreateInventoryItemRequest(
|
||||
ShopId, _createName.Trim(), _createItemTypeId, _createUnit,
|
||||
_createCostPerUnit, _createInitialQty, _createReorderLevel,
|
||||
string.IsNullOrWhiteSpace(_createSupplier) ? null : _createSupplier.Trim(),
|
||||
_createExpiryDate);
|
||||
var ok = await DataService.CreateInventoryItemAsync(req);
|
||||
_invFormMessage = ok ? $"Đã tạo nguyên liệu \"{_createName}\" thành công!" : "Lỗi khi tạo nguyên liệu.";
|
||||
_invFormSuccess = ok;
|
||||
if (ok)
|
||||
{
|
||||
_createName = ""; _createCostPerUnit = 0; _createInitialQty = 0; _createReorderLevel = 10;
|
||||
_createSupplier = ""; _createExpiryDate = null; _showCreateForm = false;
|
||||
_inventory = await DataService.GetInventoryAsync(_shopGuid);
|
||||
_invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@* EN: Recipe management component — link ingredients to inventory items for COGS calculation.
|
||||
VI: Component quản lý công thức — liên kết nguyên liệu với mặt hàng tồn kho để tính giá vốn. *@
|
||||
@using WebClientTpos.Client.Services
|
||||
@using WebClientTpos.Client.Pages.Admin.Shop
|
||||
@inject PosDataService DataService
|
||||
@@ -19,17 +21,31 @@
|
||||
<div style="grid-column:span 2;"><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Hướng dẫn</label><textarea @bind="_newRecipeInstructions" rows="2" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);resize:vertical;"></textarea></div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"><span style="font-size:13px;font-weight:600;">Nguyên liệu</span><button @onclick='() => _recipeIngredients.Add(new("","","",0,0))' style="padding:4px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-primary);font-size:12px;cursor:pointer;">+ Thêm</button></div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"><span style="font-size:13px;font-weight:600;">Nguyên liệu / Ingredients</span><button @onclick="() => _recipeIngredients.Add(new IngredientRow())" style="padding:4px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-primary);font-size:12px;cursor:pointer;">+ Thêm</button></div>
|
||||
@for (var idx = 0; idx < _recipeIngredients.Count; idx++)
|
||||
{
|
||||
var i = idx;
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr auto;gap:8px;margin-bottom:6px;">
|
||||
<input value="@_recipeIngredients[i].Name" @onchange="@(e => { var t = _recipeIngredients[i]; _recipeIngredients[i] = (e.Value?.ToString() ?? "", t.Unit, t.Qty, t.Quantity, t.Cost); })" placeholder="Tên nguyên liệu" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||
<input value="@_recipeIngredients[i].Quantity" @onchange="@(e => { var t = _recipeIngredients[i]; _recipeIngredients[i] = (t.Name, t.Unit, t.Qty, decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0, t.Cost); })" type="number" placeholder="Qty" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||
<input value="@_recipeIngredients[i].Unit" @onchange="@(e => { var t = _recipeIngredients[i]; _recipeIngredients[i] = (t.Name, e.Value?.ToString() ?? "", t.Qty, t.Quantity, t.Cost); })" placeholder="Đơn vị" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||
<input value="@_recipeIngredients[i].Cost" @onchange="@(e => { var t = _recipeIngredients[i]; _recipeIngredients[i] = (t.Name, t.Unit, t.Qty, t.Quantity, decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0); })" type="number" placeholder="Chi phí" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||
<button @onclick='() => _recipeIngredients.RemoveAt(i)' style="padding:6px;border-radius:6px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;cursor:pointer;">✕</button>
|
||||
<div style="display:grid;grid-template-columns:2fr 2fr 1fr 1fr 1fr auto;gap:8px;margin-bottom:6px;">
|
||||
@* EN: Inventory item dropdown — select raw material from inventory / VI: Dropdown chọn nguyên liệu từ kho *@
|
||||
<select value="@(_recipeIngredients[i].InventoryItemId?.ToString() ?? "")" @onchange="@(e => OnInventoryItemSelected(i, e.Value?.ToString()))" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;">
|
||||
<option value="">-- Chọn kho / Select inventory --</option>
|
||||
@foreach (var item in _inventoryItems.Where(x => x.ItemType == "RawMaterial" || x.ItemType == null))
|
||||
{
|
||||
<option value="@item.Id">@(item.ProductName ?? item.Id.ToString()[..8]) (@item.Unit - @(item.CostPerUnit?.ToString("N0") ?? "0")d)</option>
|
||||
}
|
||||
</select>
|
||||
<input value="@_recipeIngredients[i].Name" @onchange="@(e => { _recipeIngredients[i].Name = e.Value?.ToString() ?? ""; })" placeholder="Tên nguyên liệu" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||
<input value="@_recipeIngredients[i].Quantity" @onchange="@(e => { _recipeIngredients[i].Quantity = decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0; })" type="number" step="0.01" placeholder="Qty" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||
<input value="@_recipeIngredients[i].Unit" @onchange="@(e => { _recipeIngredients[i].Unit = e.Value?.ToString() ?? ""; })" placeholder="Đơn vị" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||
<input value="@_recipeIngredients[i].Cost" @onchange="@(e => { _recipeIngredients[i].Cost = decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0; })" type="number" placeholder="Chi phí" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||
<button @onclick="() => _recipeIngredients.RemoveAt(i)" style="padding:6px;border-radius:6px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;cursor:pointer;">X</button>
|
||||
</div>
|
||||
@if (_recipeIngredients[i].InventoryItemId != null)
|
||||
{
|
||||
<div style="font-size:11px;color:var(--admin-text-tertiary);margin-bottom:6px;margin-left:4px;">
|
||||
Qty/serving: <input value="@_recipeIngredients[i].QuantityPerServing" @onchange="@(e => { _recipeIngredients[i].QuantityPerServing = decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0; })" type="number" step="0.01" placeholder="Qty per serving" style="width:80px;padding:3px 6px;border-radius:4px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:11px;" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
@@ -72,14 +88,26 @@ else
|
||||
@code {
|
||||
[Parameter] public Guid ShopId { get; set; }
|
||||
|
||||
// Recipes state
|
||||
// EN: Mutable class for ingredient row editing / VI: Class có thể chỉnh sửa cho hàng nguyên liệu
|
||||
private class IngredientRow
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Unit { get; set; } = "";
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal Cost { get; set; }
|
||||
public Guid? InventoryItemId { get; set; }
|
||||
public decimal QuantityPerServing { get; set; }
|
||||
}
|
||||
|
||||
// EN: Recipes state / VI: Trạng thái công thức
|
||||
private List<PosDataService.RecipeInfo> _recipes = new();
|
||||
private List<PosDataService.InventoryItemInfo> _inventoryItems = new();
|
||||
private bool _showRecipeForm;
|
||||
private Guid? _editingRecipeId;
|
||||
private string _newRecipeName = "";
|
||||
private string _newRecipeInstructions = "";
|
||||
private int _newRecipePrepTime = 5;
|
||||
private List<(string Name, string Unit, string Qty, decimal Quantity, decimal Cost)> _recipeIngredients = new();
|
||||
private List<IngredientRow> _recipeIngredients = new();
|
||||
private Guid? _expandedRecipeId;
|
||||
private string? _recipeFormMessage;
|
||||
private bool _recipeFormSuccess;
|
||||
@@ -88,7 +116,34 @@ else
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (ShopId != Guid.Empty)
|
||||
_recipes = await DataService.GetRecipesAsync(ShopId);
|
||||
{
|
||||
// EN: Load recipes and inventory items in parallel.
|
||||
// VI: Tải công thức và mặt hàng tồn kho song song.
|
||||
var recipesTask = DataService.GetRecipesAsync(ShopId);
|
||||
var inventoryTask = DataService.GetInventoryAsync(ShopId);
|
||||
await Task.WhenAll(recipesTask, inventoryTask);
|
||||
_recipes = recipesTask.Result;
|
||||
_inventoryItems = inventoryTask.Result;
|
||||
}
|
||||
}
|
||||
|
||||
// EN: When user selects an inventory item, auto-fill name, unit, cost.
|
||||
// VI: Khi người dùng chọn mặt hàng tồn kho, tự động điền tên, đơn vị, chi phí.
|
||||
private void OnInventoryItemSelected(int index, string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var itemId))
|
||||
{
|
||||
_recipeIngredients[index].InventoryItemId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var item = _inventoryItems.FirstOrDefault(x => x.Id == itemId);
|
||||
if (item == null) return;
|
||||
|
||||
_recipeIngredients[index].InventoryItemId = itemId;
|
||||
_recipeIngredients[index].Name = item.ProductName ?? "";
|
||||
_recipeIngredients[index].Unit = item.Unit ?? "";
|
||||
_recipeIngredients[index].Cost = item.CostPerUnit ?? 0;
|
||||
}
|
||||
|
||||
// ═══ RECIPE CRUD ═══
|
||||
@@ -103,7 +158,7 @@ else
|
||||
{
|
||||
var ingredients = _recipeIngredients
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i.Name))
|
||||
.Select(i => new PosDataService.RecipeIngredientRequest(i.Name, i.Quantity, i.Unit, i.Cost))
|
||||
.Select(i => new PosDataService.RecipeIngredientRequest(i.Name, i.Quantity, i.Unit, i.Cost, i.InventoryItemId, i.QuantityPerServing))
|
||||
.ToList();
|
||||
var req = new PosDataService.CreateRecipeRequest(ShopId, Guid.Empty, _newRecipeName, _newRecipeInstructions, _newRecipePrepTime, ingredients);
|
||||
bool ok;
|
||||
|
||||
@@ -222,8 +222,17 @@ public class PosDataService
|
||||
|
||||
// ═══ INVENTORY METHODS ═══
|
||||
|
||||
// EN: Inventory item info — includes item type, unit, cost and supplier metadata.
|
||||
// VI: Thông tin mặt hàng tồn kho — bao gồm loại, đơn vị, giá nhập và nhà cung cấp.
|
||||
public record InventoryItemInfo(Guid Id, Guid ProductId, Guid ShopId, int Quantity,
|
||||
int ReorderLevel, int ReservedQuantity, DateTime? UpdatedAt, string? ProductName);
|
||||
int ReorderLevel, int ReservedQuantity, DateTime? UpdatedAt, string? ProductName,
|
||||
string? ItemType = null, string? Unit = null, decimal? CostPerUnit = null,
|
||||
string? SupplierName = null, DateTime? ExpiryDate = null);
|
||||
|
||||
// EN: Request to create a new inventory item (raw material / supply).
|
||||
// VI: Yêu cầu tạo mặt hàng tồn kho mới (nguyên liệu / vật tư).
|
||||
public record CreateInventoryItemRequest(Guid ShopId, string Name, int ItemTypeId, string Unit,
|
||||
decimal CostPerUnit, int InitialQuantity, int ReorderLevel, string? SupplierName, DateTime? ExpiryDate);
|
||||
|
||||
public async Task<List<InventoryItemInfo>> GetInventoryAsync(Guid? shopId = null)
|
||||
{
|
||||
@@ -231,6 +240,22 @@ public class PosDataService
|
||||
return await GetListFromApiAsync<InventoryItemInfo>(url);
|
||||
}
|
||||
|
||||
// EN: Create a new inventory item (raw material, finished product, or supply).
|
||||
// VI: Tạo mặt hàng tồn kho mới (nguyên liệu, thành phẩm, hoặc vật tư).
|
||||
public async Task<bool> CreateInventoryItemAsync(CreateInventoryItemRequest req)
|
||||
{
|
||||
AttachToken();
|
||||
var r = await _http.PostAsJsonAsync("api/bff/inventory/items", req, _writeOptions);
|
||||
return r.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteInventoryItemAsync(Guid inventoryItemId)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.DeleteAsync($"api/bff/inventory/items/{inventoryItemId}");
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
// ═══ MEMBERSHIP/CUSTOMER METHODS ═══
|
||||
|
||||
public record MemberInfo(Guid Id, string? CountryCode, string? Gender, int CurrentExp,
|
||||
@@ -412,7 +437,10 @@ public class PosDataService
|
||||
|
||||
// ═══ INVENTORY OPERATIONS ═══
|
||||
|
||||
public record StockInRequest(Guid ProductId, Guid ShopId, int Amount, string? Notes);
|
||||
// EN: Stock-in request — includes optional invoice image URL and unit cost.
|
||||
// VI: Yêu cầu nhập kho — bao gồm URL hóa đơn và giá nhập tùy chọn.
|
||||
public record StockInRequest(Guid ProductId, Guid ShopId, int Amount, string? Notes,
|
||||
string? InvoiceImageUrl = null, decimal? UnitCost = null, string? SupplierName = null);
|
||||
|
||||
public async Task<bool> StockInAsync(StockInRequest req)
|
||||
{
|
||||
@@ -447,6 +475,38 @@ public class PosDataService
|
||||
return await GetListFromApiAsync<LowStockItemInfo>(url);
|
||||
}
|
||||
|
||||
// ═══ WASTAGE & STOCKTAKE ═══
|
||||
|
||||
public record RecordWastageRequest(Guid InventoryItemId, int Amount, string Reason, string? Notes);
|
||||
|
||||
public async Task<bool> RecordWastageAsync(RecordWastageRequest req)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync("api/bff/inventory/wastage", req, _writeOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public record StocktakeItemRequest(Guid InventoryItemId, int CountedQuantity);
|
||||
public record StocktakeRequest(Guid ShopId, List<StocktakeItemRequest> Items);
|
||||
public record StocktakeDiscrepancy(Guid InventoryItemId, string? ItemName, int ExpectedQuantity, int CountedQuantity, int Difference);
|
||||
public record StocktakeResult(List<StocktakeDiscrepancy> Discrepancies, int TotalItemsCounted);
|
||||
|
||||
public async Task<StocktakeResult?> StocktakeAsync(StocktakeRequest req)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync("api/bff/inventory/stocktake", req, _writeOptions);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
try
|
||||
{
|
||||
var json = await resp.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("data", out var data))
|
||||
return JsonSerializer.Deserialize<StocktakeResult>(data.GetRawText(), _jsonOptions);
|
||||
return JsonSerializer.Deserialize<StocktakeResult>(json, _jsonOptions);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ═══ MEMBERSHIP LEVELS ═══
|
||||
|
||||
public record LevelDefinitionInfo(Guid Id, int LevelNumber, string Name, int RequiredExp,
|
||||
@@ -880,10 +940,14 @@ public class PosDataService
|
||||
|
||||
// ═══ RECIPES CRUD ═══
|
||||
|
||||
public record RecipeIngredientInfo(Guid Id, Guid RecipeId, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit);
|
||||
// EN: Recipe ingredient info — includes optional inventory item link for COGS.
|
||||
// VI: Thông tin nguyên liệu công thức — bao gồm liên kết tùy chọn đến mặt hàng tồn kho để tính giá vốn.
|
||||
public record RecipeIngredientInfo(Guid Id, Guid RecipeId, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit,
|
||||
Guid? InventoryItemId = null, decimal QuantityPerServing = 0);
|
||||
public record RecipeInfo(Guid Id, Guid ProductId, Guid ShopId, string Name, string? Instructions, int PrepTimeMinutes, bool IsActive, DateTime CreatedAt);
|
||||
public record CreateRecipeRequest(Guid ShopId, Guid ProductId, string Name, string? Instructions, int PrepTimeMinutes, List<RecipeIngredientRequest>? Ingredients);
|
||||
public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit);
|
||||
public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit,
|
||||
Guid? InventoryItemId = null, decimal QuantityPerServing = 0);
|
||||
|
||||
public async Task<List<RecipeInfo>> GetRecipesAsync(Guid? shopId = null)
|
||||
{
|
||||
|
||||
@@ -89,13 +89,24 @@ public class InventoryController : ControllerBase
|
||||
var obj = new Dictionary<string, object?>();
|
||||
foreach (var kv in dict)
|
||||
obj[kv.Key] = kv.Value;
|
||||
obj["productName"] = productName;
|
||||
// EN: Use item name (raw material) if available, fallback to catalog productName
|
||||
// VI: Dùng tên item (nguyên liệu) nếu có, fallback sang productName từ catalog
|
||||
var itemName = item.TryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String ? nameEl.GetString() : null;
|
||||
obj["productName"] = itemName ?? productName;
|
||||
enriched.Add(obj);
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = new { items = enriched } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new inventory item (raw material, finished product, or supply).
|
||||
/// VI: Tạo mặt hàng tồn kho mới (nguyên liệu, thành phẩm, hoặc vật tư).
|
||||
/// </summary>
|
||||
[HttpPost("inventory/items")]
|
||||
public Task<IActionResult> CreateInventoryItem([FromBody] JsonElement body) =>
|
||||
_inventory.PostAsJsonAsync("/api/v1/inventory/items", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update inventory quantity for a specific item.
|
||||
/// VI: Cập nhật số lượng tồn kho cho mặt hàng.
|
||||
@@ -139,6 +150,22 @@ public class InventoryController : ControllerBase
|
||||
public Task<IActionResult> AdjustStock([FromBody] JsonElement body) =>
|
||||
_inventory.PostAsJsonAsync("/api/v1/inventory/adjust", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Record wastage/shrinkage.
|
||||
/// VI: Ghi nhận hao hụt.
|
||||
/// </summary>
|
||||
[HttpPost("inventory/wastage")]
|
||||
public Task<IActionResult> RecordWastage([FromBody] JsonElement body) =>
|
||||
_inventory.PostAsJsonAsync("/api/v1/inventory/wastage", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Perform stocktake (inventory count).
|
||||
/// VI: Thực hiện kiểm kê (đếm tồn kho).
|
||||
/// </summary>
|
||||
[HttpPost("inventory/stocktake")]
|
||||
public Task<IActionResult> Stocktake([FromBody] JsonElement body) =>
|
||||
_inventory.PostAsJsonAsync("/api/v1/inventory/stocktake", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get low stock alerts.
|
||||
/// VI: Lấy cảnh báo hàng tồn kho thấp.
|
||||
@@ -149,4 +176,12 @@ public class InventoryController : ControllerBase
|
||||
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
|
||||
return _inventory.GetAsync($"/api/v1/inventory/low-stock{qs}").ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete an inventory item.
|
||||
/// VI: Xóa nguyên liệu khỏi tồn kho.
|
||||
/// </summary>
|
||||
[HttpDelete("inventory/items/{id:guid}")]
|
||||
public Task<IActionResult> DeleteInventoryItem(Guid id) =>
|
||||
_inventory.DeleteAsync($"/api/v1/inventory/items/{id}").ProxyAsync();
|
||||
}
|
||||
|
||||
@@ -213,6 +213,12 @@ public class OrderController : ControllerBase
|
||||
{
|
||||
writer.WriteString("productType", itemType);
|
||||
}
|
||||
// EN: Default all PreparedFood items to track inventory for auto-deduction
|
||||
// VI: Mặc định tất cả PreparedFood items theo dõi tồn kho để tự động trừ kho
|
||||
if (!item.TryGetProperty("trackInventory", out _))
|
||||
{
|
||||
writer.WriteBoolean("trackInventory", true);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
Reference in New Issue
Block a user