feat(商品): 調整商品代號顯示與會計報表樣式
This commit is contained in:
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' => '所選分類不存在',
|
||||||
|
|||||||
@@ -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: '價格',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user