diff --git a/app/Http/Controllers/AccountingReportController.php b/app/Http/Controllers/AccountingReportController.php new file mode 100644 index 0000000..929c867 --- /dev/null +++ b/app/Http/Controllers/AccountingReportController.php @@ -0,0 +1,135 @@ +input('date_start', Carbon::now()->startOfMonth()->toDateString()); + $dateEnd = $request->input('date_end', Carbon::now()->endOfMonth()->toDateString()); + + // 1. Get Purchase Orders (Completed or Received that are ready for accounting) + $purchaseOrders = PurchaseOrder::with(['vendor']) + ->whereIn('status', ['received', 'completed']) + ->whereBetween('created_at', [$dateStart . ' 00:00:00', $dateEnd . ' 23:59:59']) + ->get() + ->map(function ($po) { + return [ + 'id' => 'PO-' . $po->id, + 'date' => $po->created_at->toDateString(), + 'source' => '採購單', + 'category' => '進貨支出', + 'item' => $po->vendor->name ?? '未知廠商', + 'reference' => $po->code, + 'invoice_number' => $po->invoice_number, + 'amount' => $po->grand_total, + ]; + }); + + // 2. Get Utility Fees + $utilityFees = UtilityFee::whereBetween('transaction_date', [$dateStart, $dateEnd]) + ->get() + ->map(function ($fee) { + return [ + 'id' => 'UF-' . $fee->id, + 'date' => $fee->transaction_date, + 'source' => '公共事業費', + 'category' => $fee->category, + 'item' => $fee->description ?: $fee->category, + 'reference' => '-', + 'invoice_number' => $fee->invoice_number, + 'amount' => $fee->amount, + ]; + }); + + // Combine and Sort + $allRecords = $purchaseOrders->concat($utilityFees) + ->sortByDesc('date') + ->values(); + + $summary = [ + 'total_amount' => $allRecords->sum('amount'), + 'purchase_total' => $purchaseOrders->sum('amount'), + 'utility_total' => $utilityFees->sum('amount'), + 'record_count' => $allRecords->count(), + ]; + + return Inertia::render('Accounting/Report', [ + 'records' => $allRecords, + 'summary' => $summary, + 'filters' => [ + 'date_start' => $dateStart, + 'date_end' => $dateEnd, + ], + ]); + } + + public function export(Request $request) + { + $dateStart = $request->input('date_start', Carbon::now()->startOfMonth()->toDateString()); + $dateEnd = $request->input('date_end', Carbon::now()->endOfMonth()->toDateString()); + + $purchaseOrders = PurchaseOrder::with(['vendor']) + ->whereIn('status', ['received', 'completed']) + ->whereBetween('created_at', [$dateStart . ' 00:00:00', $dateEnd . ' 23:59:59']) + ->get(); + + $utilityFees = UtilityFee::whereBetween('transaction_date', [$dateStart, $dateEnd])->get(); + + $allRecords = collect(); + + foreach ($purchaseOrders as $po) { + $allRecords->push([ + $po->created_at->toDateString(), + '採購單', + '進貨支出', + $po->vendor->name ?? '', + $po->code, + $po->invoice_number, + $po->grand_total, + ]); + } + + foreach ($utilityFees as $fee) { + $allRecords->push([ + $fee->transaction_date, + '公共事業費', + $fee->category, + $fee->description, + '-', + $fee->invoice_number, + $fee->amount, + ]); + } + + $allRecords = $allRecords->sortByDesc(0); + + $filename = "accounting_report_{$dateStart}_{$dateEnd}.csv"; + $headers = [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => "attachment; filename=\"{$filename}\"", + ]; + + $callback = function () use ($allRecords) { + $file = fopen('php://output', 'w'); + // BOM for Excel compatibility with UTF-8 + fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); + + fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']); + + foreach ($allRecords as $row) { + fputcsv($file, $row); + } + fclose($file); + }; + + return response()->stream($callback, 200, $headers); + } +} diff --git a/app/Http/Controllers/UtilityFeeController.php b/app/Http/Controllers/UtilityFeeController.php new file mode 100644 index 0000000..c92572f --- /dev/null +++ b/app/Http/Controllers/UtilityFeeController.php @@ -0,0 +1,89 @@ +has('search')) { + $search = $request->input('search'); + $query->where(function($q) use ($search) { + $q->where('category', 'like', "%{$search}%") + ->orWhere('invoice_number', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + // Filtering + if ($request->filled('category') && $request->input('category') !== 'all') { + $query->where('category', $request->input('category')); + } + + if ($request->filled('date_start')) { + $query->where('transaction_date', '>=', $request->input('date_start')); + } + + if ($request->filled('date_end')) { + $query->where('transaction_date', '<=', $request->input('date_end')); + } + + // Sorting + $sortField = $request->input('sort_field', 'transaction_date'); + $sortDirection = $request->input('sort_direction', 'desc'); + $query->orderBy($sortField, $sortDirection); + + $fees = $query->paginate($request->input('per_page', 15))->withQueryString(); + + $availableCategories = UtilityFee::distinct()->pluck('category'); + + return Inertia::render('UtilityFee/Index', [ + 'fees' => $fees, + 'availableCategories' => $availableCategories, + 'filters' => $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction']), + ]); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'transaction_date' => 'required|date', + 'category' => 'required|string|max:255', + 'amount' => 'required|numeric|min:0', + 'invoice_number' => 'nullable|string|max:255', + 'description' => 'nullable|string', + ]); + + UtilityFee::create($validated); + + return redirect()->back(); + } + + public function update(Request $request, UtilityFee $utility_fee) + { + $validated = $request->validate([ + 'transaction_date' => 'required|date', + 'category' => 'required|string|max:255', + 'amount' => 'required|numeric|min:0', + 'invoice_number' => 'nullable|string|max:255', + 'description' => 'nullable|string', + ]); + + $utility_fee->update($validated); + + return redirect()->back(); + } + + public function destroy(UtilityFee $utility_fee) + { + $utility_fee->delete(); + return redirect()->back(); + } +} diff --git a/app/Models/UtilityFee.php b/app/Models/UtilityFee.php new file mode 100644 index 0000000..9e1d6c7 --- /dev/null +++ b/app/Models/UtilityFee.php @@ -0,0 +1,34 @@ + 'date', + 'amount' => 'decimal:2', + ]; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logAll() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } +} diff --git a/database/migrations/tenant/2026_01_20_093627_create_utility_fees_table.php b/database/migrations/tenant/2026_01_20_093627_create_utility_fees_table.php new file mode 100644 index 0000000..c88c2ce --- /dev/null +++ b/database/migrations/tenant/2026_01_20_093627_create_utility_fees_table.php @@ -0,0 +1,35 @@ +id(); + $table->date('transaction_date')->comment('費用日期'); + $table->string('category')->comment('費用類別 (例如:電費、水費、瓦斯費)'); + $table->decimal('amount', 12, 2)->comment('金額'); + $table->string('invoice_number', 20)->nullable()->comment('發票號碼'); + $table->text('description')->nullable()->comment('說明/備註'); + $table->timestamps(); + + // 常用查詢索引 + $table->index(['transaction_date', 'category']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('utility_fees'); + } +}; diff --git a/database/seeders/FinancePermissionSeeder.php b/database/seeders/FinancePermissionSeeder.php new file mode 100644 index 0000000..641230c --- /dev/null +++ b/database/seeders/FinancePermissionSeeder.php @@ -0,0 +1,49 @@ + $permission]); + } + + // 分配權限給現有角色 + + // Super Admin 獲得所有 + $superAdmin = Role::where('name', 'super-admin')->first(); + if ($superAdmin) { + $superAdmin->givePermissionTo($permissions); + } + + // Admin 獲得所有 + $admin = Role::where('name', 'admin')->first(); + if ($admin) { + $admin->givePermissionTo($permissions); + } + + // Viewer 獲得檢視權限 + $viewer = Role::where('name', 'viewer')->first(); + if ($viewer) { + $viewer->givePermissionTo([ + 'utility_fees.view', + 'accounting.view', + ]); + } + } +} diff --git a/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx new file mode 100644 index 0000000..23ed8ec --- /dev/null +++ b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx @@ -0,0 +1,211 @@ +import { useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/Components/ui/dialog"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Label } from "@/Components/ui/label"; +import { Textarea } from "@/Components/ui/textarea"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; +import { useForm } from "@inertiajs/react"; +import { toast } from "sonner"; + +export interface UtilityFee { + id: number; + transaction_date: string; + category: string; + amount: number | string; + invoice_number?: string; + description?: string; + created_at: string; + updated_at: string; +} + +interface UtilityFeeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + fee: UtilityFee | null; + availableCategories: string[]; +} + +const DEFAULT_CATEGORIES = [ + "電費", + "水費", + "瓦斯費", + "電話費", + "網路費", + "清潔費", + "管理費", +]; + +export default function UtilityFeeDialog({ + open, + onOpenChange, + fee, + availableCategories, +}: UtilityFeeDialogProps) { + const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({ + transaction_date: new Date().toISOString().split("T")[0], + category: "", + amount: "", + invoice_number: "", + description: "", + }); + + // Combine default and available categories + const categories = Array.from(new Set([...DEFAULT_CATEGORIES, ...availableCategories])); + + useEffect(() => { + if (open) { + clearErrors(); + if (fee) { + setData({ + transaction_date: fee.transaction_date, + category: fee.category, + amount: fee.amount.toString(), + invoice_number: fee.invoice_number || "", + description: fee.description || "", + }); + } else { + reset(); + setData("transaction_date", new Date().toISOString().split("T")[0]); + } + } + }, [open, fee]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (fee) { + put(route("utility-fees.update", fee.id), { + onSuccess: () => { + toast.success("紀錄已更新"); + onOpenChange(false); + reset(); + }, + onError: () => { + toast.error("更新失敗,請檢查輸入資料"); + } + }); + } else { + post(route("utility-fees.store"), { + onSuccess: () => { + toast.success("公共事業費已記錄"); + onOpenChange(false); + reset(); + }, + onError: () => { + toast.error("紀錄失敗,請檢查輸入資料"); + } + }); + } + }; + + return ( + + + + {fee ? "編輯費用紀錄" : "新增費用紀錄"} + + {fee ? "修改此筆公共事業費的詳細資訊" : "記錄一筆新的公共事業費支出"} + + + +
+
+
+ + setData("transaction_date", e.target.value)} + className={errors.transaction_date ? "border-red-500" : ""} + required + /> + {errors.transaction_date &&

{errors.transaction_date}

} +
+ +
+
+ + setData("category", value)} + options={categories.map((c) => ({ label: c, value: c }))} + placeholder="選擇或輸入類別" + searchPlaceholder="搜尋類別..." + className={errors.category ? "border-red-500" : ""} + /> + {errors.category &&

{errors.category}

} +
+ +
+ + setData("amount", e.target.value)} + placeholder="0.00" + className={errors.amount ? "border-red-500" : ""} + required + /> + {errors.amount &&

{errors.amount}

} +
+
+ +
+ + setData("invoice_number", e.target.value)} + placeholder="例:AB12345678" + /> + {errors.invoice_number &&

{errors.invoice_number}

} +
+ +
+ +