[FEAT] 實作公共事業費附件上傳管理與更新 UI 協作規範防呆機制
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
This commit is contained in:
@@ -62,6 +62,10 @@ trigger: always_on
|
|||||||
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||||
|
* **🔴 核心要求:UI 規範與彈性設計 (重要)**:
|
||||||
|
* 在開發「新功能」或「新頁面」前,產出的 `implementation_plan.md` 中**必須包含「UI 規範核對清單」**,明確列出將使用哪些已定義於 `ui-consistency/SKILL.md` 的元件(例如:`AlertDialog`、特定圖示名稱等)。
|
||||||
|
* **已規範部分**:絕對遵循《客戶端後台 UI 統一規範》進行實作。
|
||||||
|
* **未規範部分**:若遇到規範外的新 UI 區塊,請保有設計彈性,運用 Tailwind 打造符合 ERP 調性的初版設計,依據使用者的實際感受進行後續調整與收錄。
|
||||||
|
|
||||||
## 8. 多租戶開發規範 (Multi-tenancy Standards)
|
## 8. 多租戶開發規範 (Multi-tenancy Standards)
|
||||||
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
|
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ namespace App\Modules\Finance\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Finance\Models\UtilityFee;
|
use App\Modules\Finance\Models\UtilityFee;
|
||||||
|
use App\Modules\Finance\Models\UtilityFeeAttachment;
|
||||||
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
class UtilityFeeController extends Controller
|
class UtilityFeeController extends Controller
|
||||||
@@ -103,8 +105,82 @@ class UtilityFeeController extends Controller
|
|||||||
->event('deleted')
|
->event('deleted')
|
||||||
->log('deleted');
|
->log('deleted');
|
||||||
|
|
||||||
|
// 刪除實體檔案 (如果 cascade 沒處理或是想要手動清理)
|
||||||
|
foreach ($utility_fee->attachments as $attachment) {
|
||||||
|
Storage::disk('public')->delete($attachment->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
$utility_fee->delete();
|
$utility_fee->delete();
|
||||||
|
|
||||||
return redirect()->back();
|
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' => '刪除成功']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,16 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
|
|
||||||
class UtilityFee extends Model
|
class UtilityFee extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UtilityFeeFactory> */
|
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 此公共事業費的附件
|
||||||
|
*/
|
||||||
|
public function attachments()
|
||||||
|
{
|
||||||
|
return $this->hasMany(UtilityFeeAttachment::class);
|
||||||
|
}
|
||||||
|
|
||||||
// 狀態常數
|
// 狀態常數
|
||||||
const STATUS_PENDING = 'pending';
|
const STATUS_PENDING = 'pending';
|
||||||
const STATUS_PAID = 'paid';
|
const STATUS_PAID = 'paid';
|
||||||
|
|||||||
36
app/Modules/Finance/Models/UtilityFeeAttachment.php
Normal file
36
app/Modules/Finance/Models/UtilityFeeAttachment.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Finance\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class UtilityFeeAttachment extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'utility_fee_id',
|
||||||
|
'file_path',
|
||||||
|
'original_name',
|
||||||
|
'mime_type',
|
||||||
|
'size',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = ['url'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件所属的公共事業費
|
||||||
|
*/
|
||||||
|
public function utilityFee(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(UtilityFee::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 獲取附件的全路徑 URL
|
||||||
|
*/
|
||||||
|
public function getUrlAttribute()
|
||||||
|
{
|
||||||
|
return tenant_asset($this->file_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,11 @@ Route::middleware('auth')->group(function () {
|
|||||||
});
|
});
|
||||||
Route::middleware('permission:utility_fees.edit')->group(function () {
|
Route::middleware('permission:utility_fees.edit')->group(function () {
|
||||||
Route::put('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'update'])->name('utility-fees.update');
|
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::middleware('permission:utility_fees.delete')->group(function () {
|
||||||
Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy');
|
Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy');
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class FinanceService implements FinanceServiceInterface
|
|||||||
|
|
||||||
public function getUtilityFees(array $filters)
|
public function getUtilityFees(array $filters)
|
||||||
{
|
{
|
||||||
$query = UtilityFee::query();
|
$query = UtilityFee::withCount('attachments');
|
||||||
|
|
||||||
if (!empty($filters['search'])) {
|
if (!empty($filters['search'])) {
|
||||||
$search = $filters['search'];
|
$search = $filters['search'];
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('utility_fee_attachments', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
304
resources/js/Components/UtilityFee/AttachmentDialog.tsx
Normal file
304
resources/js/Components/UtilityFee/AttachmentDialog.tsx
Normal file
@@ -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<Attachment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[550px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
|
<FileText className="h-6 w-6 text-primary-main" />
|
||||||
|
附件管理
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
管理 {fee?.billing_month} {fee?.category_name} 的支援文件(對帳單、收據等)。
|
||||||
|
最多可上傳 3 個檔案,單一檔案上限 2MB。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-6">
|
||||||
|
{/* 上傳區塊 */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-dashed border-slate-300">
|
||||||
|
<div className="text-sm text-slate-500">
|
||||||
|
{attachments.length < 3
|
||||||
|
? `還可以上傳 ${3 - attachments.length} 個附件`
|
||||||
|
: "已達到上傳數量上限"}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file-upload"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={uploading || attachments.length >= 3}
|
||||||
|
accept=".jpg,.jpeg,.png,.webp,.pdf"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
disabled={uploading || attachments.length >= 3}
|
||||||
|
className="button-filled-primary"
|
||||||
|
>
|
||||||
|
<label htmlFor="file-upload" className="cursor-pointer flex items-center">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
選擇檔案
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 附件列表 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-2" />
|
||||||
|
<p>載入中...</p>
|
||||||
|
</div>
|
||||||
|
) : attachments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 bg-slate-50 rounded-xl border border-slate-100">
|
||||||
|
<p className="text-slate-400">目前尚無附件</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{attachments.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="group relative flex items-center justify-between p-3 bg-white rounded-xl border border-slate-200 hover:border-primary-light transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<div className="h-12 w-12 rounded-lg bg-slate-100 flex items-center justify-center overflow-hidden shrink-0 border border-slate-100">
|
||||||
|
{file.mime_type.startsWith("image/") ? (
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={file.original_name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = "";
|
||||||
|
(e.target as HTMLImageElement).className = "hidden";
|
||||||
|
(e.target as HTMLImageElement).parentElement?.classList.add("bg-slate-200");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileText className="h-6 w-6 text-blue-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<p className="text-sm font-medium text-slate-900 truncate pr-2" title={file.original_name}>
|
||||||
|
{file.original_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{formatSize(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary h-8 w-8 p-0"
|
||||||
|
asChild
|
||||||
|
title="另開分頁"
|
||||||
|
>
|
||||||
|
<a href={file.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-error h-8 w-8 p-0"
|
||||||
|
onClick={() => confirmDelete(file.id)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
title="刪除附件"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>確認刪除附件?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
這將永久刪除此附件,此操作無法撤銷。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
確認刪除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,13 +20,19 @@ import { validateInvoiceNumber } from "@/utils/validation";
|
|||||||
|
|
||||||
export interface UtilityFee {
|
export interface UtilityFee {
|
||||||
id: number;
|
id: number;
|
||||||
transaction_date: string | null;
|
billing_month: string;
|
||||||
due_date: string;
|
category_id: number;
|
||||||
category: string;
|
category?: string; // 相容於舊版或特定視圖
|
||||||
|
category_name?: string;
|
||||||
amount: number | 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;
|
invoice_number?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
attachments_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { Badge } from "@/Components/ui/badge";
|
import { Badge } from "@/Components/ui/badge";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import UtilityFeeDialog, { UtilityFee } from "@/Components/UtilityFee/UtilityFeeDialog";
|
import UtilityFeeDialog, { UtilityFee } from "@/Components/UtilityFee/UtilityFeeDialog";
|
||||||
|
import AttachmentDialog from "@/Components/UtilityFee/AttachmentDialog";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -77,8 +78,19 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
|||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [isAttachmentDialogOpen, setIsAttachmentDialogOpen] = useState(false);
|
||||||
const [editingFee, setEditingFee] = useState<UtilityFee | null>(null);
|
const [editingFee, setEditingFee] = useState<UtilityFee | null>(null);
|
||||||
const [deletingFeeId, setDeletingFeeId] = useState<number | null>(null);
|
const [deletingFeeId, setDeletingFeeId] = useState<number | null>(null);
|
||||||
|
const [attachmentFee, setAttachmentFee] = useState<UtilityFee | null>(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 }:
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Can permission="utility_fees.edit">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary relative"
|
||||||
|
onClick={() => openAttachmentDialog(fee)}
|
||||||
|
title="附件管理"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
{(fee.attachments_count || 0) > 0 && (
|
||||||
|
<span className="absolute -top-2 -right-2 flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-[10px] text-white font-bold ring-2 ring-white animate-in zoom-in">
|
||||||
|
{fee.attachments_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
<Can permission="utility_fees.edit">
|
<Can permission="utility_fees.edit">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -510,6 +538,13 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
|||||||
availableCategories={availableCategories}
|
availableCategories={availableCategories}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AttachmentDialog
|
||||||
|
open={isAttachmentDialogOpen}
|
||||||
|
onOpenChange={setIsAttachmentDialogOpen}
|
||||||
|
fee={attachmentFee}
|
||||||
|
onAttachmentsChange={handleAttachmentsChange}
|
||||||
|
/>
|
||||||
|
|
||||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user