ERP-node/frontend/components/report/ReportListTable.tsx

660 lines
23 KiB
TypeScript

"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { ReportMaster } from "@/types/report";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Copy, Trash2, Edit, Eye, FileText, Calendar, User, Loader2, Pencil, AlignLeft } from "lucide-react";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { getTypeBgClass, getTypeLabel, getTypeIcon } from "@/lib/reportTypeColors";
interface ReportListTableProps {
reports: ReportMaster[];
total: number;
page: number;
limit: number;
isLoading: boolean;
viewMode: "grid" | "list";
onPageChange: (page: number) => void;
onRefresh: () => void;
onViewClick: (report: ReportMaster) => void;
onCopyClick: (report: ReportMaster) => void;
}
export function ReportListTable({
reports,
total,
page,
limit,
isLoading,
viewMode,
onPageChange,
onRefresh,
onViewClick,
onCopyClick,
}: ReportListTableProps) {
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast();
const router = useRouter();
const totalPages = Math.ceil(total / limit);
const handleEdit = (reportId: string) => {
router.push(`/admin/screenMng/reportList/designer/${reportId}`);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const response = await reportApi.deleteReport(deleteTarget);
if (response.success) {
toast({ title: "성공", description: "리포트가 삭제되었습니다." });
setDeleteTarget(null);
onRefresh();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트 삭제에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
try {
return format(new Date(dateString), "yyyy-MM-dd");
} catch {
return dateString;
}
};
const formatUpdatedDate = (updatedAt: string | null, createdAt: string | null) => {
if (!updatedAt) return "-";
try {
const updatedStr = format(new Date(updatedAt), "yyyy-MM-dd HH:mm:ss");
const createdStr = createdAt ? format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss") : null;
if (createdStr && updatedStr === createdStr) return "-";
return format(new Date(updatedAt), "yyyy-MM-dd");
} catch {
return updatedAt || "-";
}
};
if (isLoading) {
return (
<div className="flex h-72 items-center justify-center">
<Loader2 className="text-muted-foreground h-10 w-10 animate-spin" />
</div>
);
}
if (reports.length === 0) {
return (
<div className="text-muted-foreground flex h-72 flex-col items-center justify-center gap-3">
<FileText className="h-12 w-12 opacity-30" />
<p className="text-base"> .</p>
</div>
);
}
const handleRename = async (reportId: string, newName: string) => {
try {
const response = await reportApi.updateReport(reportId, { reportNameKor: newName });
if (response.success) {
toast({ title: "성공", description: "리포트명이 변경되었습니다." });
onRefresh();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트명 변경에 실패했습니다.",
variant: "destructive",
});
}
};
const handleDescriptionChange = async (reportId: string, newDesc: string) => {
try {
const response = await reportApi.updateReport(reportId, { description: newDesc });
if (response.success) {
toast({ title: "성공", description: "설명이 변경되었습니다." });
onRefresh();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "설명 변경에 실패했습니다.",
variant: "destructive",
});
}
};
return (
<>
{viewMode === "grid" ? (
<GridView
reports={reports}
page={page}
limit={limit}
onEdit={handleEdit}
onView={onViewClick}
onCopyClick={onCopyClick}
onDeleteClick={setDeleteTarget}
onRename={handleRename}
onDescriptionChange={handleDescriptionChange}
formatDate={formatDate}
formatUpdatedDate={formatUpdatedDate}
/>
) : (
<ListView
reports={reports}
page={page}
limit={limit}
onEdit={handleEdit}
onView={onViewClick}
onCopyClick={onCopyClick}
onDeleteClick={setDeleteTarget}
onRename={handleRename}
onDescriptionChange={handleDescriptionChange}
formatDate={formatDate}
formatUpdatedDate={formatUpdatedDate}
/>
)}
{/* 페이지네이션 */}
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-7 py-4">
<span className="text-base text-gray-500">
<span className="font-semibold text-gray-900">{total}</span>
</span>
{totalPages > 1 && (
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
className="h-9 px-4 text-sm"
>
</Button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(p)}
className={`h-9 w-9 p-0 text-sm ${
p === page ? "bg-blue-600 text-white hover:bg-blue-700" : ""
}`}
>
{p}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page === totalPages}
className="h-9 px-4 text-sm"
>
</Button>
</div>
)}
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-base">
?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting} className="text-base"></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 text-base"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
...
</>
) : (
"삭제"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
interface ViewProps {
reports: ReportMaster[];
page: number;
limit: number;
onEdit: (id: string) => void;
onView: (report: ReportMaster) => void;
onCopyClick: (report: ReportMaster) => void;
onDeleteClick: (id: string) => void;
onRename: (reportId: string, newName: string) => Promise<void>;
onDescriptionChange: (reportId: string, newDesc: string) => Promise<void>;
formatDate: (d: string | null) => string;
formatUpdatedDate: (updatedAt: string | null, createdAt: string | null) => string;
}
function InlineReportName({
reportId,
name,
onNavigate,
onRename,
}: {
reportId: string;
name: string;
onNavigate: () => void;
onRename: (reportId: string, newName: string) => Promise<void>;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(name);
const [isSaving, setIsSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditing]);
const handleSave = useCallback(async () => {
const trimmed = editValue.trim();
if (!trimmed || trimmed === name) {
setIsEditing(false);
setEditValue(name);
return;
}
setIsSaving(true);
try {
await onRename(reportId, trimmed);
} finally {
setIsSaving(false);
setIsEditing(false);
}
}, [editValue, name, reportId, onRename]);
const handleCancel = useCallback(() => {
setIsEditing(false);
setEditValue(name);
}, [name]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
handleCancel();
}
},
[handleSave, handleCancel],
);
if (isEditing) {
return (
<div className="flex min-w-0 items-center gap-1.5">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
disabled={isSaving}
className="h-7 text-sm font-medium"
/>
{isSaving && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-gray-400" />}
</div>
);
}
return (
<div className="group/name flex min-w-0 items-center gap-1.5">
<button
onClick={onNavigate}
className="cursor-pointer truncate text-left text-base font-medium text-gray-900 hover:text-blue-600 hover:underline"
>
{name}
</button>
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-gray-100 group-hover/name:opacity-100"
title="리포트명 수정"
>
<Pencil className="h-3.5 w-3.5 text-gray-400" />
</button>
</div>
);
}
function InlineDescription({
reportId,
description,
onSave,
}: {
reportId: string;
description: string | null;
onSave: (reportId: string, newDesc: string) => Promise<void>;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(description || "");
const [isSaving, setIsSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
inputRef.current?.focus();
}
}, [isEditing]);
const handleSave = useCallback(async () => {
const trimmed = editValue.trim();
if (trimmed === (description || "")) {
setIsEditing(false);
setEditValue(description || "");
return;
}
setIsSaving(true);
try {
await onSave(reportId, trimmed);
} finally {
setIsSaving(false);
setIsEditing(false);
}
}, [editValue, description, reportId, onSave]);
const handleCancel = useCallback(() => {
setIsEditing(false);
setEditValue(description || "");
}, [description]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
handleCancel();
}
},
[handleSave, handleCancel],
);
if (isEditing) {
return (
<div className="flex min-w-0 items-center gap-1.5">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
disabled={isSaving}
placeholder="설명 입력"
className="h-6 text-xs"
/>
{isSaving && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-gray-400" />}
</div>
);
}
return (
<div className="group/desc flex min-w-0 items-center gap-1.5">
<span
className="cursor-pointer truncate text-gray-500 hover:text-gray-700"
onClick={() => setIsEditing(true)}
title={description || "클릭하여 설명 입력"}
>
{description || <span className="italic text-gray-300">Inline Description</span>}
</span>
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-gray-100 group-hover/desc:opacity-100"
title="설명 수정"
>
<Pencil className="h-3 w-3 text-gray-400" />
</button>
</div>
);
}
function ListView({ reports, page, limit, onEdit, onView, onCopyClick, onDeleteClick, onRename, onDescriptionChange, formatDate, formatUpdatedDate }: ViewProps) {
return (
<div className="overflow-x-auto">
<table className="w-full table-fixed">
<colgroup>
<col className="w-[16px]" />
<col className="w-[46px]" />
<col style={{ width: "26%" }} />
<col style={{ width: "18%" }} />
<col className="w-[120px]" />
<col className="w-[110px]" />
<col className="w-[110px]" />
<col className="w-[110px]" />
<col className="w-[340px]" />
<col className="w-[16px]" />
</colgroup>
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th />
<th className="px-2 py-3.5 text-center text-sm font-semibold text-gray-500">NO</th>
<th className="py-3.5 text-center text-sm font-semibold text-gray-500"></th>
<th className="py-3.5 pl-20 pr-3 text-left text-sm font-semibold text-gray-500"></th>
<th className="px2 py-3.5 text-left text-sm font-semibold text-gray-500"></th>
<th className="px-3 py-3.5 text-left text-sm font-semibold text-gray-500"></th>
<th className="px-3 py-3.5 text-left text-sm font-semibold text-gray-500"></th>
<th className="px-3 py-3.5 text-left text-sm font-semibold text-gray-500"></th>
<th className="px-4 py-3.5 text-center text-sm font-semibold text-gray-500"></th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{reports.map((report, index) => {
const rowNumber = (page - 1) * limit + index + 1;
return (
<tr key={report.report_id} className="transition-colors hover:bg-blue-50/70">
<td />
<td className="px-2 py-4 text-center text-sm font-medium text-gray-400">
{rowNumber}
</td>
<td className="pl-1 py-4 pl-10 pr-2">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-blue-600">
<FileText className="h-3 w-3 text-white" />
</div>
<InlineReportName
reportId={report.report_id}
name={report.report_name_kor}
onNavigate={() => onEdit(report.report_id)}
onRename={onRename}
/>
</div>
</td>
<td className="min-w-0 overflow-hidden px-3 py-4">
<InlineDescription
reportId={report.report_id}
description={report.description}
onSave={onDescriptionChange}
/>
</td>
<td className="px-3 py-4">
{report.report_type && (() => {
const TypeIcon = getTypeIcon(report.report_type);
return (
<Badge className={`gap-1.5 whitespace-nowrap text-sm leading-tight hover:bg-transparent ${getTypeBgClass(report.report_type)}`}>
<TypeIcon className="h-3.5 w-3.5" strokeWidth={2.2} />
{getTypeLabel(report.report_type)}
</Badge>
);
})()}
</td>
<td className="px-3 py-4 text-sm text-gray-600">
{report.created_by || "-"}
</td>
<td className="px-3 py-4 text-sm text-gray-500">
{formatDate(report.created_at)}
</td>
<td className="px-3 py-4 text-sm text-gray-500">
{formatUpdatedDate(report.updated_at, report.created_at)}
</td>
<td className="px-4 py-4">
<div className="flex items-center justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => onView(report)} className="h-9 gap-1.5 px-3 text-sm font-medium">
<Eye className="h-4 w-4" strokeWidth={2.2} />
</Button>
<Button variant="outline" size="sm" onClick={() => onEdit(report.report_id)} className="h-9 gap-1.5 px-3 text-sm font-medium">
<Edit className="h-4 w-4" strokeWidth={2.2} />
</Button>
<Button variant="outline" size="sm" onClick={() => onCopyClick(report)} className="h-9 gap-1.5 px-3 text-sm font-medium">
<Copy className="h-4 w-4" strokeWidth={2.2} />
</Button>
<Button variant="outline" size="sm" onClick={() => onDeleteClick(report.report_id)} className="h-9 gap-1.5 px-3 text-sm font-medium text-red-600 hover:border-red-200 hover:bg-red-50 hover:text-red-700">
<Trash2 className="h-4 w-4" strokeWidth={2.2} />
</Button>
</div>
</td>
<td />
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function GridView({ reports, page, limit, onEdit, onView, onCopyClick, onDeleteClick, onRename, onDescriptionChange, formatDate, formatUpdatedDate }: ViewProps) {
return (
<div className="grid grid-cols-1 gap-3 px-4 py-4 md:grid-cols-2 lg:grid-cols-3">
{reports.map((report, index) => {
const rowNumber = (page - 1) * limit + index + 1;
return (
<div
key={report.report_id}
className="group rounded-lg border border-gray-200 bg-white px-4 py-3 transition-all hover:border-blue-300 hover:bg-blue-50/50 hover:shadow-md"
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="ml-1 flex min-w-0 items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-blue-600">
<FileText className="h-4 w-4 text-white" />
</div>
<InlineReportName
reportId={report.report_id}
name={report.report_name_kor}
onNavigate={() => onEdit(report.report_id)}
onRename={onRename}
/>
{report.report_type && (() => {
const TypeIcon = getTypeIcon(report.report_type);
return (
<Badge className={`-ml-1.5 shrink-0 gap-1 text-[11px] leading-tight hover:bg-transparent ${getTypeBgClass(report.report_type)}`}>
<TypeIcon className="h-2.5 w-2.5" strokeWidth={2.5} />
{getTypeLabel(report.report_type)}
</Badge>
);
})()}
</div>
<span className="shrink-0 text-[11px] font-medium text-gray-400">#{rowNumber}</span>
</div>
<div className="ml-3 space-y-1 text-xs">
<div className="flex items-center text-gray-600">
<User className="mr-1.5 h-3.5 w-3.5 shrink-0" />
<span className="w-[38px] shrink-0 font-medium text-gray-700"></span>
<span>{report.created_by || "-"}</span>
</div>
<div className="flex items-center text-gray-600">
<Calendar className="mr-1.5 h-3.5 w-3.5 shrink-0" />
<span className="w-[38px] shrink-0 font-medium text-gray-700"></span>
<span>{formatDate(report.created_at)}</span>
</div>
<div className="flex items-center text-gray-600">
<Calendar className="mr-1.5 h-3.5 w-3.5 shrink-0" />
<span className="w-[38px] shrink-0 font-medium text-gray-700"></span>
<span>{formatUpdatedDate(report.updated_at, report.created_at)}</span>
</div>
<div className="flex items-center text-gray-600">
<AlignLeft className="mr-1.5 h-3.5 w-3.5 shrink-0" />
<span className="w-[38px] shrink-0 font-medium text-gray-700"></span>
<div className="min-w-0 flex-1">
<InlineDescription
reportId={report.report_id}
description={report.description}
onSave={onDescriptionChange}
/>
</div>
</div>
</div>
<div className="mt-2 flex gap-1.5 border-t border-gray-100 pt-2">
<Button variant="outline" size="sm" onClick={() => onView(report)} className="h-7 flex-1 gap-1 px-0 text-[11px]">
<Eye className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => onEdit(report.report_id)} className="h-7 flex-1 gap-1 px-0 text-[11px]">
<Edit className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => onCopyClick(report)} className="h-7 flex-1 gap-1 px-0 text-[11px]">
<Copy className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => onDeleteClick(report.report_id)} className="h-7 flex-1 gap-1 px-0 text-[11px] text-red-600 hover:border-red-200 hover:bg-red-50 hover:text-red-700">
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
);
}