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 (
+
+ );
+}
diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx
index 7e0a9c9..91c80bb 100644
--- a/resources/js/Layouts/AuthenticatedLayout.tsx
+++ b/resources/js/Layouts/AuthenticatedLayout.tsx
@@ -17,7 +17,10 @@ import {
Settings,
Shield,
Users,
- FileText
+ FileText,
+ Wallet,
+ BarChart3,
+ FileSpreadsheet
} from "lucide-react";
import { toast, Toaster } from "sonner";
import { useState, useEffect, useMemo } from "react";
@@ -126,6 +129,36 @@ export default function AuthenticatedLayout({
},
],
},
+ {
+ id: "finance-management",
+ label: "財務管理",
+ icon:
彙整採購支出與各項公用事業費用
+共有 {summary.record_count} 筆紀錄
+採購單彙整
+水、電、瓦斯、電信等費項
+管理店鋪水、電、瓦斯等各項公共事業費用支出
+