feat(商品): 調整商品代號顯示與會計報表樣式
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 54s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

This commit is contained in:
2026-01-21 16:30:50 +08:00
parent af5f2f55ab
commit fc20c6d813
8 changed files with 101 additions and 25 deletions

View File

@@ -90,37 +90,58 @@ class AccountingReportController extends Controller
{ {
$dateStart = $request->input('date_start', Carbon::now()->toDateString()); $dateStart = $request->input('date_start', Carbon::now()->toDateString());
$dateEnd = $request->input('date_end', Carbon::now()->toDateString()); $dateEnd = $request->input('date_end', Carbon::now()->toDateString());
$selectedIdsParam = $request->input('selected_ids');
$purchaseOrders = PurchaseOrder::with(['vendor']) $purchaseOrdersQuery = PurchaseOrder::with(['vendor'])
->whereIn('status', ['received', 'completed']) ->whereIn('status', ['received', 'completed']);
->whereBetween('created_at', [$dateStart . ' 00:00:00', $dateEnd . ' 23:59:59'])
->get();
$utilityFees = UtilityFee::whereBetween('transaction_date', [$dateStart, $dateEnd])->get(); $utilityFeesQuery = UtilityFee::query();
if ($selectedIdsParam) {
$ids = explode(',', $selectedIdsParam);
$poIds = [];
$ufIds = [];
foreach ($ids as $id) {
if (str_starts_with($id, 'PO-')) {
$poIds[] = substr($id, 3);
} elseif (str_starts_with($id, 'UF-')) {
$ufIds[] = substr($id, 3);
}
}
$purchaseOrders = $purchaseOrdersQuery->whereIn('id', $poIds)->get();
$utilityFees = $utilityFeesQuery->whereIn('id', $ufIds)->get();
} else {
$purchaseOrders = $purchaseOrdersQuery
->whereBetween('created_at', [$dateStart . ' 00:00:00', $dateEnd . ' 23:59:59'])
->get();
$utilityFees = $utilityFeesQuery
->whereBetween('transaction_date', [$dateStart, $dateEnd])
->get();
}
$allRecords = collect(); $allRecords = collect();
foreach ($purchaseOrders as $po) { foreach ($purchaseOrders as $po) {
$allRecords->push([ $allRecords->push([
$po->created_at->toDateString(), Carbon::parse($po->created_at)->toDateString(),
'採購單', '採購單',
'進貨支出', '進貨支出',
$po->vendor->name ?? '', $po->vendor->name ?? '',
$po->code, $po->code,
$po->invoice_number, $po->invoice_number,
$po->grand_total, (float)$po->grand_total,
]); ]);
} }
foreach ($utilityFees as $fee) { foreach ($utilityFees as $fee) {
$allRecords->push([ $allRecords->push([
$fee->transaction_date, Carbon::parse($fee->transaction_date)->toDateString(),
'公共事業費', '公共事業費',
$fee->category, $fee->category,
$fee->description, $fee->description,
'-', '-',
$fee->invoice_number, $fee->invoice_number,
$fee->amount, (float)$fee->amount,
]); ]);
} }

View File

@@ -75,6 +75,7 @@ class ProductController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$validated = $request->validate([ $validated = $request->validate([
'code' => 'required|string|max:2|unique:products,code',
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id', 'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255', 'brand' => 'nullable|string|max:255',
@@ -85,6 +86,9 @@ class ProductController extends Controller
'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',
], [ ], [
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 2 碼',
'code.unique' => '商品代號已存在',
'name.required' => '商品名稱為必填', 'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類', 'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在', 'category_id.exists' => '所選分類不存在',
@@ -95,14 +99,6 @@ class ProductController extends Controller
'conversion_rate.min' => '換算率最小為 0.0001', 'conversion_rate.min' => '換算率最小為 0.0001',
]); ]);
// Auto-generate code
$prefix = 'P';
$lastProduct = Product::withTrashed()->latest('id')->first();
$nextId = $lastProduct ? $lastProduct->id + 1 : 1;
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
$validated['code'] = $code;
$product = Product::create($validated); $product = Product::create($validated);
return redirect()->back()->with('success', '商品已建立'); return redirect()->back()->with('success', '商品已建立');
@@ -114,6 +110,7 @@ class ProductController extends Controller
public function update(Request $request, Product $product) public function update(Request $request, Product $product)
{ {
$validated = $request->validate([ $validated = $request->validate([
'code' => 'required|string|max:2|unique:products,code,' . $product->id,
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id', 'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255', 'brand' => 'nullable|string|max:255',
@@ -123,6 +120,9 @@ class ProductController extends Controller
'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',
], [ ], [
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 2 碼',
'code.unique' => '商品代號已存在',
'name.required' => '商品名稱為必填', 'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類', 'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在', 'category_id.exists' => '所選分類不存在',

View File

@@ -44,7 +44,7 @@ interface Props {
// Field translation map // Field translation map
const fieldLabels: Record<string, string> = { const fieldLabels: Record<string, string> = {
name: '名稱', name: '名稱',
code: '代碼', code: '商品代號',
username: '登入帳號', username: '登入帳號',
description: '描述', description: '描述',
price: '價格', price: '價格',

View File

@@ -78,7 +78,7 @@ export default function BarcodeViewDialog({
<div class="barcode-container"> <div class="barcode-container">
<div class="product-info"> <div class="product-info">
<div class="product-name">${productName}</div> <div class="product-name">${productName}</div>
<div class="product-code">商品號: ${productCode}</div> <div class="product-code">商品號: ${productCode}</div>
</div> </div>
<img src="${barcodePlaceholder}" alt="商品條碼" /> <img src="${barcodePlaceholder}" alt="商品條碼" />
</div> </div>

View File

@@ -35,6 +35,7 @@ export default function ProductDialog({
units, units,
}: ProductDialogProps) { }: ProductDialogProps) {
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({ const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
code: "",
name: "", name: "",
category_id: "", category_id: "",
brand: "", brand: "",
@@ -50,6 +51,7 @@ export default function ProductDialog({
clearErrors(); clearErrors();
if (product) { if (product) {
setData({ setData({
code: product.code,
name: product.name, name: product.name,
category_id: product.category_id.toString(), category_id: product.category_id.toString(),
brand: product.brand || "", brand: product.brand || "",
@@ -142,6 +144,21 @@ export default function ProductDialog({
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>} {errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
</div> </div>
<div className="space-y-2">
<Label htmlFor="code">
<span className="text-red-500">*</span>
</Label>
<Input
id="code"
value={data.code}
onChange={(e) => setData("code", e.target.value)}
placeholder="例A1 (最多2碼)"
maxLength={2}
className={errors.code ? "border-red-500" : ""}
/>
{errors.code && <p className="text-sm text-red-500">{errors.code}</p>}
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="brand"></Label> <Label htmlFor="brand"></Label>
<Input <Input

View File

@@ -76,7 +76,7 @@ export default function ProductTable({
<TableHead className="w-[50px] text-center">#</TableHead> <TableHead className="w-[50px] text-center">#</TableHead>
<TableHead> <TableHead>
<button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900"> <button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900">
<SortIcon field="code" /> <SortIcon field="code" />
</button> </button>
</TableHead> </TableHead>
<TableHead> <TableHead>

View File

@@ -28,6 +28,7 @@ import { Badge } from "@/Components/ui/badge";
import Pagination from "@/Components/shared/Pagination"; import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Can } from "@/Components/Permission/Can"; import { Can } from "@/Components/Permission/Can";
import { Checkbox } from "@/Components/ui/checkbox";
interface Record { interface Record {
id: string; id: string;
@@ -71,6 +72,7 @@ export default function AccountingReport({ records, summary, filters }: PageProp
const initialRangeType = (filters.date_start === today && filters.date_end === today) ? "today" : "custom"; const initialRangeType = (filters.date_start === today && filters.date_end === today) ? "today" : "custom";
const [dateRangeType, setDateRangeType] = useState(initialRangeType); const [dateRangeType, setDateRangeType] = useState(initialRangeType);
const [perPage, setPerPage] = useState(filters.per_page?.toString() || "10"); const [perPage, setPerPage] = useState(filters.per_page?.toString() || "10");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const handleDateRangeChange = (type: string) => { const handleDateRangeChange = (type: string) => {
setDateRangeType(type); setDateRangeType(type);
@@ -111,14 +113,35 @@ export default function AccountingReport({ records, summary, filters }: PageProp
setDateStart(""); setDateStart("");
setDateEnd(""); setDateEnd("");
setPerPage("10"); setPerPage("10");
setSelectedIds([]);
router.get(route("accounting.report"), {}, { preserveState: false }); router.get(route("accounting.report"), {}, { preserveState: false });
}; };
const toggleSelectAll = () => {
if (selectedIds.length === records.data.length) {
setSelectedIds([]);
} else {
setSelectedIds(records.data.map(r => r.id));
}
};
const toggleSelect = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
const handleExport = () => { const handleExport = () => {
window.location.href = route("accounting.export", { const query: any = {
date_start: dateStart, date_start: dateStart,
date_end: dateEnd, date_end: dateEnd,
}); };
if (selectedIds.length > 0) {
query.selected_ids = selectedIds.join(',');
}
window.location.href = route("accounting.export", query);
}; };
return ( return (
@@ -142,7 +165,8 @@ export default function AccountingReport({ records, summary, filters }: PageProp
variant="outline" variant="outline"
className="button-outlined-primary gap-2" className="button-outlined-primary gap-2"
> >
<Download className="h-4 w-4" /> CSV <Download className="h-4 w-4" />
{selectedIds.length > 0 ? `匯出已選 (${selectedIds.length})` : '匯出 CSV 報表'}
</Button> </Button>
</Can> </Can>
</div> </div>
@@ -265,6 +289,13 @@ export default function AccountingReport({ records, summary, filters }: PageProp
<Table> <Table>
<TableHeader className="bg-gray-50"> <TableHeader className="bg-gray-50">
<TableRow> <TableRow>
<TableHead className="w-[50px] text-center">
<Checkbox
checked={records.data.length > 0 && selectedIds.length === records.data.length}
onCheckedChange={toggleSelectAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-[140px] text-center"></TableHead> <TableHead className="w-[140px] text-center"></TableHead>
<TableHead className="w-[120px] text-center"></TableHead> <TableHead className="w-[120px] text-center"></TableHead>
<TableHead className="w-[140px] text-center"></TableHead> <TableHead className="w-[140px] text-center"></TableHead>
@@ -275,7 +306,7 @@ export default function AccountingReport({ records, summary, filters }: PageProp
<TableBody> <TableBody>
{records.data.length === 0 ? ( {records.data.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5}> <TableCell colSpan={6}>
<div className="flex flex-col items-center justify-center space-y-2 py-8 text-gray-400"> <div className="flex flex-col items-center justify-center space-y-2 py-8 text-gray-400">
<FileText className="h-10 w-10 opacity-20" /> <FileText className="h-10 w-10 opacity-20" />
<p></p> <p></p>
@@ -285,6 +316,13 @@ export default function AccountingReport({ records, summary, filters }: PageProp
) : ( ) : (
records.data.map((record) => ( records.data.map((record) => (
<TableRow key={record.id}> <TableRow key={record.id}>
<TableCell className="text-center">
<Checkbox
checked={selectedIds.includes(record.id)}
onCheckedChange={() => toggleSelect(record.id)}
aria-label={`Select record ${record.id}`}
/>
</TableCell>
<TableCell className="font-medium text-gray-700 text-center"> <TableCell className="font-medium text-gray-700 text-center">
{formatDateWithDayOfWeek(record.date)} {formatDateWithDayOfWeek(record.date)}
</TableCell> </TableCell>

View File

@@ -191,7 +191,7 @@ export default function ProductManagement({ products, categories, units, filters
<div className="flex-1 relative"> <div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input <Input
placeholder="搜尋商品名稱、商品號、品牌..." placeholder="搜尋商品名稱、商品號、品牌..."
value={searchTerm} value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10" className="pl-10 pr-10"