diff --git a/.agents/rules/framework.md b/.agents/rules/framework.md index 91cfb80..e6ff1c7 100644 --- a/.agents/rules/framework.md +++ b/.agents/rules/framework.md @@ -62,6 +62,10 @@ trigger: always_on * 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。 * 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。 * 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。 + * **🔴 核心要求:UI 規範與彈性設計 (重要)**: + * 在開發「新功能」或「新頁面」前,產出的 `implementation_plan.md` 中**必須包含「UI 規範核對清單」**,明確列出將使用哪些已定義於 `ui-consistency/SKILL.md` 的元件(例如:`AlertDialog`、特定圖示名稱等)。 + * **已規範部分**:絕對遵循《客戶端後台 UI 統一規範》進行實作。 + * **未規範部分**:若遇到規範外的新 UI 區塊,請保有設計彈性,運用 Tailwind 打造符合 ERP 調性的初版設計,依據使用者的實際感受進行後續調整與收錄。 ## 8. 多租戶開發規範 (Multi-tenancy Standards) 本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則: diff --git a/app/Modules/Finance/Controllers/UtilityFeeController.php b/app/Modules/Finance/Controllers/UtilityFeeController.php index 9cc7738..b8c00fe 100644 --- a/app/Modules/Finance/Controllers/UtilityFeeController.php +++ b/app/Modules/Finance/Controllers/UtilityFeeController.php @@ -4,8 +4,10 @@ namespace App\Modules\Finance\Controllers; use App\Http\Controllers\Controller; use App\Modules\Finance\Models\UtilityFee; +use App\Modules\Finance\Models\UtilityFeeAttachment; use App\Modules\Finance\Contracts\FinanceServiceInterface; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; use Inertia\Inertia; class UtilityFeeController extends Controller @@ -103,8 +105,82 @@ class UtilityFeeController extends Controller ->event('deleted') ->log('deleted'); + // 刪除實體檔案 (如果 cascade 沒處理或是想要手動清理) + foreach ($utility_fee->attachments as $attachment) { + Storage::disk('public')->delete($attachment->file_path); + } + $utility_fee->delete(); return redirect()->back(); } + + /** + * 獲取附件列表 + */ + public function attachments(UtilityFee $utility_fee) + { + return response()->json([ + 'attachments' => $utility_fee->attachments()->orderBy('created_at', 'desc')->get() + ]); + } + + /** + * 上傳附件 + */ + public function uploadAttachment(Request $request, UtilityFee $utility_fee) + { + $request->validate([ + 'file' => 'required|file|mimes:jpeg,jpg,png,webp,pdf|max:2048', // 2MB + ]); + + // 檢查數量限制 (最多 3 張) + if ($utility_fee->attachments()->count() >= 3) { + return response()->json(['message' => '附件數量已達上限 (最多 3 個)'], 422); + } + + $file = $request->file('file'); + $path = $file->store("utility-fee-attachments/{$utility_fee->id}", 'public'); + + $attachment = $utility_fee->attachments()->create([ + 'file_path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getMimeType(), + 'size' => $file->getSize(), + ]); + + activity() + ->performedOn($utility_fee) + ->causedBy(auth()->user()) + ->event('attachment_uploaded') + ->log("uploaded attachment: {$attachment->original_name}"); + + return response()->json([ + 'message' => '上傳成功', + 'attachment' => $attachment + ]); + } + + /** + * 刪除附件 + */ + public function deleteAttachment(UtilityFee $utility_fee, UtilityFeeAttachment $attachment) + { + // 確保附件屬於該費用 + if ($attachment->utility_fee_id !== $utility_fee->id) { + abort(403); + } + + Storage::disk('public')->delete($attachment->file_path); + + $attachment->delete(); + + activity() + ->performedOn($utility_fee) + ->causedBy(auth()->user()) + ->event('attachment_deleted') + ->log("deleted attachment: {$attachment->original_name}"); + + return response()->json(['message' => '刪除成功']); + } } diff --git a/app/Modules/Finance/Models/UtilityFee.php b/app/Modules/Finance/Models/UtilityFee.php index aba6528..d362b50 100644 --- a/app/Modules/Finance/Models/UtilityFee.php +++ b/app/Modules/Finance/Models/UtilityFee.php @@ -7,9 +7,16 @@ use Illuminate\Database\Eloquent\Model; class UtilityFee extends Model { - /** @use HasFactory<\Database\Factories\UtilityFeeFactory> */ use HasFactory; + /** + * 此公共事業費的附件 + */ + public function attachments() + { + return $this->hasMany(UtilityFeeAttachment::class); + } + // 狀態常數 const STATUS_PENDING = 'pending'; const STATUS_PAID = 'paid'; diff --git a/app/Modules/Finance/Models/UtilityFeeAttachment.php b/app/Modules/Finance/Models/UtilityFeeAttachment.php new file mode 100644 index 0000000..f9b0575 --- /dev/null +++ b/app/Modules/Finance/Models/UtilityFeeAttachment.php @@ -0,0 +1,36 @@ +belongsTo(UtilityFee::class); + } + + /** + * 獲取附件的全路徑 URL + */ + public function getUrlAttribute() + { + return tenant_asset($this->file_path); + } +} diff --git a/app/Modules/Finance/Routes/web.php b/app/Modules/Finance/Routes/web.php index 1ec273d..01aae1f 100644 --- a/app/Modules/Finance/Routes/web.php +++ b/app/Modules/Finance/Routes/web.php @@ -30,6 +30,11 @@ Route::middleware('auth')->group(function () { }); Route::middleware('permission:utility_fees.edit')->group(function () { Route::put('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'update'])->name('utility-fees.update'); + + // 附件管理 (Ajax) + Route::get('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'attachments'])->name('utility-fees.attachments'); + Route::post('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'uploadAttachment'])->name('utility-fees.upload-attachment'); + Route::delete('/utility-fees/{utility_fee}/attachments/{attachment}', [UtilityFeeController::class, 'deleteAttachment'])->name('utility-fees.delete-attachment'); }); Route::middleware('permission:utility_fees.delete')->group(function () { Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy'); diff --git a/app/Modules/Finance/Services/FinanceService.php b/app/Modules/Finance/Services/FinanceService.php index 45ab42d..36dffed 100644 --- a/app/Modules/Finance/Services/FinanceService.php +++ b/app/Modules/Finance/Services/FinanceService.php @@ -67,7 +67,7 @@ class FinanceService implements FinanceServiceInterface public function getUtilityFees(array $filters) { - $query = UtilityFee::query(); + $query = UtilityFee::withCount('attachments'); if (!empty($filters['search'])) { $search = $filters['search']; diff --git a/database/migrations/tenant/2026_03_06_113117_create_utility_fee_attachments_table.php b/database/migrations/tenant/2026_03_06_113117_create_utility_fee_attachments_table.php new file mode 100644 index 0000000..a36bb2f --- /dev/null +++ b/database/migrations/tenant/2026_03_06_113117_create_utility_fee_attachments_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('utility_fee_id')->constrained('utility_fees')->cascadeOnDelete(); + $table->string('file_path'); // 儲存路徑 + $table->string('original_name'); // 原始檔名 + $table->string('mime_type'); // MIME 類型 + $table->unsignedBigInteger('size'); // 檔案大小 (bytes) + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('utility_fee_attachments'); + } +}; diff --git a/resources/js/Components/UtilityFee/AttachmentDialog.tsx b/resources/js/Components/UtilityFee/AttachmentDialog.tsx new file mode 100644 index 0000000..469ba24 --- /dev/null +++ b/resources/js/Components/UtilityFee/AttachmentDialog.tsx @@ -0,0 +1,304 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogDescription, + DialogTitle, +} from "@/Components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/Components/ui/alert-dialog"; +import { Button } from "@/Components/ui/button"; +import { + FileText, + Loader2, + Trash2, + Eye, + Upload +} from "lucide-react"; +import { UtilityFee } from "./UtilityFeeDialog"; +import { toast } from "sonner"; +import axios from "axios"; + +interface Attachment { + id: number; + url: string; + original_name: string; + mime_type: string; + size: number; +} + + + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + fee: UtilityFee | null; + onAttachmentsChange?: () => void; +} + +export default function AttachmentDialog({ + open, + onOpenChange, + fee, + onAttachmentsChange, +}: Props) { + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteId, setDeleteId] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + useEffect(() => { + if (open && fee) { + fetchAttachments(); + } else { + setAttachments([]); + } + }, [open, fee]); + + const fetchAttachments = async () => { + if (!fee) return; + setLoading(true); + try { + const response = await axios.get(route("utility-fees.attachments", fee.id)); + setAttachments(response.data.attachments || []); + } catch (error) { + toast.error("取得附件失敗"); + } finally { + setLoading(false); + } + }; + + const handleUpload = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0 || !fee) return; + + const file = files[0]; + + // 驗證檔案大小 (2MB) + if (file.size > 2 * 1024 * 1024) { + toast.error("檔案大小不能超過 2MB"); + return; + } + + // 驗證檔案類型 + const allowedTypes = ["image/jpeg", "image/png", "image/webp", "application/pdf"]; + if (!allowedTypes.includes(file.type)) { + toast.error("僅支援 JPG, PNG, WebP 圖片及 PDF 文件"); + return; + } + + // 驗證數量限制 (3張) + if (attachments.length >= 3) { + toast.error("最多僅可上傳 3 個附件"); + return; + } + + const formData = new FormData(); + formData.append("file", file); + + setUploading(true); + try { + await axios.post(route("utility-fees.upload-attachment", fee.id), formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + toast.success("上傳成功"); + fetchAttachments(); + onAttachmentsChange?.(); + } catch (error: any) { + const errorMsg = error.response?.data?.message || Object.values(error.response?.data?.errors || {})[0] || "上傳失敗"; + toast.error(errorMsg as string); + } finally { + setUploading(false); + // 清空 input 讓同一個檔案可以重複觸發 onChange + e.target.value = ""; + } + }; + + const confirmDelete = (id: number) => { + setDeleteId(id); + setIsDeleteDialogOpen(true); + }; + + const handleDelete = async () => { + if (!deleteId || !fee) return; + + setIsDeleting(true); + try { + await axios.delete(route("utility-fees.delete-attachment", [fee.id, deleteId])); + toast.success("附件已刪除"); + setAttachments(attachments.filter((a) => a.id !== deleteId)); + onAttachmentsChange?.(); + } catch (error) { + toast.error("刪除失敗"); + } finally { + setIsDeleting(false); + setDeleteId(null); + setIsDeleteDialogOpen(false); + } + }; + + const formatSize = (bytes: number) => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }; + + return ( + <> + + + + + + 附件管理 + + + 管理 {fee?.billing_month} {fee?.category_name} 的支援文件(對帳單、收據等)。 + 最多可上傳 3 個檔案,單一檔案上限 2MB。 + + + +
+ {/* 上傳區塊 */} +
+
+ {attachments.length < 3 + ? `還可以上傳 ${3 - attachments.length} 個附件` + : "已達到上傳數量上限"} +
+ = 3} + accept=".jpg,.jpeg,.png,.webp,.pdf" + /> + +
+ + {/* 附件列表 */} +
+ {loading ? ( +
+ +

載入中...

+
+ ) : attachments.length === 0 ? ( +
+

目前尚無附件

+
+ ) : ( +
+ {attachments.map((file) => ( +
+
+
+ {file.mime_type.startsWith("image/") ? ( + {file.original_name} { + (e.target as HTMLImageElement).src = ""; + (e.target as HTMLImageElement).className = "hidden"; + (e.target as HTMLImageElement).parentElement?.classList.add("bg-slate-200"); + }} + /> + ) : ( + + )} +
+
+

+ {file.original_name} +

+

+ {formatSize(file.size)} +

+
+
+ +
+ + +
+
+ ))} +
+ )} +
+
+
+
+ + + + + 確認刪除附件? + + 這將永久刪除此附件,此操作無法撤銷。 + + + + 取消 + + 確認刪除 + + + + + + ); +} diff --git a/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx index 98e1951..83ee3ee 100644 --- a/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx +++ b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx @@ -20,13 +20,19 @@ import { validateInvoiceNumber } from "@/utils/validation"; export interface UtilityFee { id: number; - transaction_date: string | null; - due_date: string; - category: string; + billing_month: string; + category_id: number; + category?: string; // 相容於舊版或特定視圖 + category_name?: string; amount: number | string; - payment_status: 'pending' | 'paid' | 'overdue'; + status: string; + payment_status?: 'pending' | 'paid' | 'overdue'; // 相容於舊版 + due_date: string; + payment_date?: string; + transaction_date?: string; // 相容於舊版 invoice_number?: string; description?: string; + attachments_count?: number; created_at: string; updated_at: string; } diff --git a/resources/js/Pages/UtilityFee/Index.tsx b/resources/js/Pages/UtilityFee/Index.tsx index 8e54c00..fcd9c97 100644 --- a/resources/js/Pages/UtilityFee/Index.tsx +++ b/resources/js/Pages/UtilityFee/Index.tsx @@ -30,6 +30,7 @@ import { import { Badge } from "@/Components/ui/badge"; import { toast } from "sonner"; import UtilityFeeDialog, { UtilityFee } from "@/Components/UtilityFee/UtilityFeeDialog"; +import AttachmentDialog from "@/Components/UtilityFee/AttachmentDialog"; import { AlertDialog, AlertDialogAction, @@ -77,8 +78,19 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }: const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isAttachmentDialogOpen, setIsAttachmentDialogOpen] = useState(false); const [editingFee, setEditingFee] = useState(null); const [deletingFeeId, setDeletingFeeId] = useState(null); + const [attachmentFee, setAttachmentFee] = useState(null); + + const openAttachmentDialog = (fee: UtilityFee) => { + setAttachmentFee(fee); + setIsAttachmentDialogOpen(true); + }; + + const handleAttachmentsChange = () => { + router.reload({ only: ['fees'] }); + }; @@ -447,6 +459,22 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
+ + +