From c1204923783e0c20831e6b7f4676374e1b81fa63 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 9 Mar 2026 22:07:11 +0900 Subject: [PATCH] [agent-pipeline] pipe-20260309122600-xzeg round-1 --- .../(main)/admin/approvalTemplate/page.tsx | 261 ++++------ .../(main)/admin/batch-management/page.tsx | 402 ++++++--------- frontend/app/(main)/admin/standards/page.tsx | 462 +++++++----------- .../app/(main)/admin/system-notices/page.tsx | 292 +++++------ frontend/components/admin/CompanyTable.tsx | 281 +++-------- frontend/components/admin/UserAuthTable.tsx | 230 ++++----- frontend/components/admin/UserTable.tsx | 427 +++++++--------- frontend/components/dataflow/DataFlowList.tsx | 313 ++++-------- 8 files changed, 1002 insertions(+), 1666 deletions(-) diff --git a/frontend/app/(main)/admin/approvalTemplate/page.tsx b/frontend/app/(main)/admin/approvalTemplate/page.tsx index bf13602b..a69d198e 100644 --- a/frontend/app/(main)/admin/approvalTemplate/page.tsx +++ b/frontend/app/(main)/admin/approvalTemplate/page.tsx @@ -6,14 +6,6 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { Dialog, DialogContent, @@ -62,6 +54,7 @@ import { deleteApprovalTemplate, } from "@/lib/api/approval"; import { getUserList } from "@/lib/api/user"; +import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView"; // ============================================================ // 타입 정의 @@ -295,7 +288,6 @@ function StepEditor({ return (
- {/* 단계 헤더 */}
@@ -314,7 +306,6 @@ function StepEditor({
- {/* step_type 선택 */}
- {/* 통보 타입 안내 */} {step.step_type === "notification" && (

(자동 처리됩니다 - 통보 대상자에게 알림만 발송)

)} - {/* 결재자 목록 */}
{step.approvers.map((approver, aIdx) => (
@@ -433,7 +422,6 @@ function StepEditor({ ))}
- {/* 합의 타입일 때만 결재자 추가 버튼 */} {step.step_type === "consensus" && (
- {/* 템플릿 목록 */} - {loading ? ( - <> - {/* 데스크톱 스켈레톤 */} -
- - - - 템플릿명 - 설명 - 단계 구성 - 연결된 유형 - 생성일 - 관리 - - - - {Array.from({ length: 5 }).map((_, i) => ( - -
-
-
-
-
-
- - ))} - -
-
- {/* 모바일 스켈레톤 */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
-
-
- {Array.from({ length: 3 }).map((_, j) => ( -
-
-
-
- ))} -
-
- ))} -
- - ) : filtered.length === 0 ? ( -
-

등록된 결재 템플릿이 없습니다.

-
- ) : ( - <> - {/* 데스크톱 테이블 */} -
- - - - 템플릿명 - 설명 - 단계 구성 - 연결된 유형 - 생성일 - 관리 - - - - {filtered.map((tpl) => ( - - {tpl.template_name} - - {tpl.description || "-"} - - {renderStepSummary(tpl)} - {tpl.definition_name || "-"} - - {formatDate(tpl.created_at)} - - -
- - -
-
-
- ))} -
-
-
- {/* 모바일 카드 */} -
- {filtered.map((tpl) => ( -
-
-

{tpl.template_name}

- {tpl.definition_name && ( - {tpl.definition_name} - )} -
- {tpl.description && ( -

{tpl.description}

- )} -
-
- 단계 구성 -
{renderStepSummary(tpl)}
-
-
- 생성일 - {formatDate(tpl.created_at)} -
-
-
- - -
-
- ))} -
- - )} + + data={filtered} + columns={columns} + keyExtractor={(tpl) => String(tpl.template_id)} + isLoading={loading} + emptyMessage="등록된 결재 템플릿이 없습니다." + skeletonCount={5} + cardTitle={(tpl) => tpl.template_name} + cardSubtitle={(tpl) => tpl.description ? ( + {tpl.description} + ) : undefined} + cardHeaderRight={(tpl) => tpl.definition_name ? ( + {tpl.definition_name} + ) : undefined} + cardFields={cardFields} + actionsLabel="관리" + actionsWidth="100px" + renderActions={(tpl) => ( + <> + + + + )} + />
{/* 등록/수정 Dialog */} @@ -913,7 +838,6 @@ export default function ApprovalTemplatePage() {
- {/* 템플릿 기본 정보 */}
- {/* 결재 단계 편집 영역 */}
diff --git a/frontend/app/(main)/admin/batch-management/page.tsx b/frontend/app/(main)/admin/batch-management/page.tsx index f191acc7..fbf0185b 100644 --- a/frontend/app/(main)/admin/batch-management/page.tsx +++ b/frontend/app/(main)/admin/batch-management/page.tsx @@ -13,14 +13,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { DropdownMenu, DropdownMenuContent, @@ -44,6 +36,7 @@ import { toast } from "sonner"; import { BatchAPI, BatchJob } from "@/lib/api/batch"; import BatchJobModal from "@/components/admin/BatchJobModal"; import { showErrorToast } from "@/lib/utils/toastUtils"; +import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView"; export default function BatchManagementPage() { const router = useRouter(); @@ -55,7 +48,6 @@ export default function BatchManagementPage() { const [typeFilter, setTypeFilter] = useState("all"); const [jobTypes, setJobTypes] = useState>([]); - // 모달 상태 const [isModalOpen, setIsModalOpen] = useState(false); const [selectedJob, setSelectedJob] = useState(null); const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false); @@ -94,7 +86,6 @@ export default function BatchManagementPage() { const filterJobs = () => { let filtered = jobs; - // 검색어 필터 if (searchTerm) { filtered = filtered.filter(job => job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) || @@ -102,12 +93,10 @@ export default function BatchManagementPage() { ); } - // 상태 필터 if (statusFilter !== "all") { filtered = filtered.filter(job => job.is_active === statusFilter); } - // 타입 필터 if (typeFilter !== "all") { filtered = filtered.filter(job => job.job_type === typeFilter); } @@ -124,12 +113,10 @@ export default function BatchManagementPage() { setIsBatchTypeModalOpen(false); if (type === 'db-to-db') { - // 기존 배치 생성 모달 열기 console.log("DB → DB 배치 모달 열기"); setSelectedJob(null); setIsModalOpen(true); } else if (type === 'restapi-to-db') { - // 새로운 REST API 배치 페이지로 이동 console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new'); router.push('/admin/batch-management-new'); } @@ -189,6 +176,149 @@ export default function BatchManagementPage() { return Math.round((job.success_count / job.execution_count) * 100); }; + const getSuccessRateColor = (rate: number) => { + if (rate >= 90) return 'text-success'; + if (rate >= 70) return 'text-warning'; + return 'text-destructive'; + }; + + const columns: RDVColumn[] = [ + { + key: "job_name", + label: "작업명", + render: (_val, job) => ( +
+
{job.job_name}
+ {job.description && ( +
{job.description}
+ )} +
+ ), + }, + { + key: "job_type", + label: "타입", + hideOnMobile: true, + render: (_val, job) => getTypeBadge(job.job_type), + }, + { + key: "schedule_cron", + label: "스케줄", + hideOnMobile: true, + render: (_val, job) => ( + {job.schedule_cron || "-"} + ), + }, + { + key: "is_active", + label: "상태", + width: "100px", + render: (_val, job) => getStatusBadge(job.is_active), + }, + { + key: "execution_count", + label: "실행", + hideOnMobile: true, + render: (_val, job) => ( +
+
총 {job.execution_count}회
+
+ 성공 {job.success_count} / 실패 {job.failure_count} +
+
+ ), + }, + { + key: "success_rate", + label: "성공률", + hideOnMobile: true, + render: (_val, job) => { + const rate = getSuccessRate(job); + return ( + + {rate}% + + ); + }, + }, + { + key: "last_executed_at", + label: "마지막 실행", + render: (_val, job) => ( + + {job.last_executed_at + ? new Date(job.last_executed_at).toLocaleString() + : "-"} + + ), + }, + ]; + + const cardFields: RDVCardField[] = [ + { + label: "타입", + render: (job) => getTypeBadge(job.job_type), + }, + { + label: "스케줄", + render: (job) => ( + {job.schedule_cron || "-"} + ), + }, + { + label: "실행 횟수", + render: (job) => {job.execution_count}회, + }, + { + label: "성공률", + render: (job) => { + const rate = getSuccessRate(job); + return ( + + {rate}% + + ); + }, + }, + { + label: "마지막 실행", + render: (job) => ( + + {job.last_executed_at + ? new Date(job.last_executed_at).toLocaleDateString() + : "-"} + + ), + }, + ]; + + const renderDropdownActions = (job: BatchJob) => ( + + + + + + handleEdit(job)}> + + 수정 + + handleExecute(job)} + disabled={job.is_active !== "Y"} + > + + 실행 + + handleDelete(job)}> + + 삭제 + + + + ); + return (
{/* 헤더 */} @@ -312,231 +442,23 @@ export default function BatchManagementPage() { 총 {filteredJobs.length}
- {isLoading ? ( - <> - {/* 데스크톱 스켈레톤 */} -
- - - - 작업명 - 타입 - 스케줄 - 상태 - 실행 통계 - 성공률 - 마지막 실행 - 작업 - - - - {Array.from({ length: 5 }).map((_, i) => ( - -
-
-
-
-
-
-
-
-
- ))} -
-
-
- {/* 모바일 스켈레톤 */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
- {Array.from({ length: 3 }).map((_, j) => ( -
-
-
-
- ))} -
-
- ))} -
- - ) : filteredJobs.length === 0 ? ( -
- {jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."} -
- ) : ( - <> - {/* 데스크톱 테이블 */} -
- - - - 작업명 - 타입 - 스케줄 - 상태 - 실행 통계 - 성공률 - 마지막 실행 - 작업 - - - - {filteredJobs.map((job) => ( - - -
-
{job.job_name}
- {job.description && ( -
{job.description}
- )} -
-
- {getTypeBadge(job.job_type)} - {job.schedule_cron || "-"} - {getStatusBadge(job.is_active)} - -
-
총 {job.execution_count}회
-
- 성공 {job.success_count} / 실패 {job.failure_count} -
-
-
- - = 90 ? 'text-success' : - getSuccessRate(job) >= 70 ? 'text-warning' : 'text-destructive' - }`}> - {getSuccessRate(job)}% - - - - {job.last_executed_at - ? new Date(job.last_executed_at).toLocaleString() - : "-"} - - - - - - - - handleEdit(job)}> - - 수정 - - handleExecute(job)} - disabled={job.is_active !== "Y"} - > - - 실행 - - handleDelete(job)}> - - 삭제 - - - - -
- ))} -
-
-
- - {/* 모바일 카드 */} -
- {filteredJobs.map((job) => ( -
-
-
-

{job.job_name}

- {job.description && ( -

{job.description}

- )} -
-
{getStatusBadge(job.is_active)}
-
-
-
- 타입 - {getTypeBadge(job.job_type)} -
-
- 스케줄 - {job.schedule_cron || "-"} -
-
- 실행 횟수 - {job.execution_count}회 -
-
- 성공률 - = 90 ? 'text-success' : - getSuccessRate(job) >= 70 ? 'text-warning' : 'text-destructive' - }`}> - {getSuccessRate(job)}% - -
-
- 마지막 실행 - - {job.last_executed_at - ? new Date(job.last_executed_at).toLocaleDateString() - : "-"} - -
-
-
- - - -
-
- ))} -
- - )} + + data={filteredJobs} + columns={columns} + keyExtractor={(job) => String(job.id)} + isLoading={isLoading} + emptyMessage={jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."} + skeletonCount={5} + cardTitle={(job) => job.job_name} + cardSubtitle={(job) => job.description ? ( + {job.description} + ) : undefined} + cardHeaderRight={(job) => getStatusBadge(job.is_active)} + cardFields={cardFields} + actionsLabel="작업" + actionsWidth="80px" + renderActions={renderDropdownActions} + /> {/* 배치 타입 선택 모달 */} {isBatchTypeModalOpen && ( @@ -547,7 +469,6 @@ export default function BatchManagementPage() {
- {/* DB → DB */}
handleBatchTypeSelect('db-to-db')} @@ -563,7 +484,6 @@ export default function BatchManagementPage() {
- {/* REST API → DB */}
handleBatchTypeSelect('restapi-to-db')} diff --git a/frontend/app/(main)/admin/standards/page.tsx b/frontend/app/(main)/admin/standards/page.tsx index 67a43bab..8a91f3f3 100644 --- a/frontend/app/(main)/admin/standards/page.tsx +++ b/frontend/app/(main)/admin/standards/page.tsx @@ -4,7 +4,6 @@ import React, { useState, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { AlertDialog, @@ -22,6 +21,8 @@ import { showErrorToast } from "@/lib/utils/toastUtils"; import { Plus, Search, Edit, Trash2, Eye, RotateCcw, SortAsc, SortDesc } from "lucide-react"; import { useWebTypes } from "@/hooks/admin/useWebTypes"; import Link from "next/link"; +import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView"; +import type { WebTypeStandard } from "@/hooks/admin/useWebTypes"; export default function WebTypesManagePage() { const [searchTerm, setSearchTerm] = useState(""); @@ -30,35 +31,29 @@ export default function WebTypesManagePage() { const [sortField, setSortField] = useState("sort_order"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); - // 웹타입 데이터 조회 const { webTypes, isLoading, error, deleteWebType, isDeleting, deleteError, refetch } = useWebTypes({ active: activeFilter === "all" ? undefined : activeFilter, search: searchTerm || undefined, category: categoryFilter === "all" ? undefined : categoryFilter, }); - // 카테고리 목록 생성 const categories = useMemo(() => { const uniqueCategories = Array.from(new Set(webTypes.map((wt) => wt.category).filter(Boolean))); return uniqueCategories.sort(); }, [webTypes]); - // 필터링 및 정렬된 데이터 const filteredAndSortedWebTypes = useMemo(() => { let filtered = [...webTypes]; - // 정렬 filtered.sort((a, b) => { let aValue: any = a[sortField as keyof typeof a]; let bValue: any = b[sortField as keyof typeof b]; - // 숫자 필드 처리 if (sortField === "sort_order") { aValue = aValue || 0; bValue = bValue || 0; } - // 문자열 필드 처리 if (typeof aValue === "string") { aValue = aValue.toLowerCase(); } @@ -74,17 +69,6 @@ export default function WebTypesManagePage() { return filtered; }, [webTypes, sortField, sortDirection]); - // 정렬 변경 핸들러 - const handleSort = (field: string) => { - if (sortField === field) { - setSortDirection(sortDirection === "asc" ? "desc" : "asc"); - } else { - setSortField(field); - setSortDirection("asc"); - } - }; - - // 삭제 핸들러 const handleDelete = async (webType: string, typeName: string) => { try { await deleteWebType(webType); @@ -94,7 +78,6 @@ export default function WebTypesManagePage() { } }; - // 필터 초기화 const resetFilters = () => { setSearchTerm(""); setCategoryFilter("all"); @@ -103,6 +86,116 @@ export default function WebTypesManagePage() { setSortDirection("asc"); }; + // 삭제 AlertDialog 렌더 헬퍼 + const renderDeleteDialog = (wt: WebTypeStandard) => ( + + + + + + + 웹타입 삭제 + + '{wt.type_name}' 웹타입을 삭제하시겠습니까? +
이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + handleDelete(wt.web_type, wt.type_name)} + disabled={isDeleting} + className="bg-destructive hover:bg-destructive/90" + > + {isDeleting ? "삭제 중..." : "삭제"} + + +
+
+ ); + + const columns: RDVColumn[] = [ + { + key: "sort_order", + label: "순서", + width: "80px", + render: (_val, wt) => {wt.sort_order || 0}, + }, + { + key: "web_type", + label: "웹타입 코드", + hideOnMobile: true, + render: (_val, wt) => {wt.web_type}, + }, + { + key: "type_name", + label: "웹타입명", + render: (_val, wt) => ( +
+
{wt.type_name}
+ {wt.type_name_eng && ( +
{wt.type_name_eng}
+ )} +
+ ), + }, + { + key: "category", + label: "카테고리", + render: (_val, wt) => {wt.category}, + }, + { + key: "description", + label: "설명", + hideOnMobile: true, + render: (_val, wt) => ( + {wt.description || "-"} + ), + }, + { + key: "is_active", + label: "상태", + render: (_val, wt) => ( + + {wt.is_active === "Y" ? "활성화" : "비활성화"} + + ), + }, + { + key: "updated_date", + label: "최종 수정일", + hideOnMobile: true, + render: (_val, wt) => ( + + {wt.updated_date ? new Date(wt.updated_date).toLocaleDateString("ko-KR") : "-"} + + ), + }, + ]; + + const cardFields: RDVCardField[] = [ + { + label: "카테고리", + render: (wt) => {wt.category}, + }, + { + label: "순서", + render: (wt) => String(wt.sort_order || 0), + }, + { + label: "설명", + render: (wt) => wt.description || "-", + hideEmpty: true, + }, + { + label: "수정일", + render: (wt) => + wt.updated_date ? new Date(wt.updated_date).toLocaleDateString("ko-KR") : "-", + }, + ]; + return (
{/* 페이지 헤더 */} @@ -165,6 +258,32 @@ export default function WebTypesManagePage() { 비활성화 + + {/* 정렬 선택 */} +
+ + +
)} - {isLoading ? ( - <> - {/* 데스크톱 스켈레톤 */} -
- - - - 순서 - 웹타입 코드 - 웹타입명 - 카테고리 - 설명 - 상태 - 최종 수정일 - 작업 - - - - {Array.from({ length: 6 }).map((_, i) => ( - -
-
-
-
-
-
-
-
-
- ))} -
-
-
- {/* 모바일 스켈레톤 */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
-
-
- {Array.from({ length: 4 }).map((_, j) => ( -
-
-
-
- ))} -
-
- ))} -
- - ) : filteredAndSortedWebTypes.length === 0 ? ( -
- 조건에 맞는 웹타입이 없습니다. -
- ) : ( - <> - {/* 데스크톱 테이블 */} -
- - - - handleSort("sort_order")}> -
- 순서 - {sortField === "sort_order" && - (sortDirection === "asc" ? : )} -
-
- handleSort("web_type")}> -
- 웹타입 코드 - {sortField === "web_type" && - (sortDirection === "asc" ? : )} -
-
- handleSort("type_name")}> -
- 웹타입명 - {sortField === "type_name" && - (sortDirection === "asc" ? : )} -
-
- handleSort("category")}> -
- 카테고리 - {sortField === "category" && - (sortDirection === "asc" ? : )} -
-
- 설명 - handleSort("is_active")}> -
- 상태 - {sortField === "is_active" && - (sortDirection === "asc" ? : )} -
-
- handleSort("updated_date")}> -
- 최종 수정일 - {sortField === "updated_date" && - (sortDirection === "asc" ? : )} -
-
- 작업 -
-
- - {filteredAndSortedWebTypes.map((webType) => ( - - {webType.sort_order || 0} - {webType.web_type} - -
{webType.type_name}
- {webType.type_name_eng && ( -
{webType.type_name_eng}
- )} -
- - {webType.category} - - {webType.description || "-"} - - - {webType.is_active === "Y" ? "활성화" : "비활성화"} - - - - {webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"} - - -
- - - - - - - - - - - - - 웹타입 삭제 - - '{webType.type_name}' 웹타입을 삭제하시겠습니까? -
이 작업은 되돌릴 수 없습니다. -
-
- - 취소 - handleDelete(webType.web_type, webType.type_name)} - disabled={isDeleting} - className="bg-destructive hover:bg-destructive/90" - > - {isDeleting ? "삭제 중..." : "삭제"} - - -
-
-
-
-
- ))} -
-
-
- - {/* 모바일 카드 */} -
- {filteredAndSortedWebTypes.map((webType) => ( -
-
-
-

{webType.type_name}

- {webType.type_name_eng && ( -

{webType.type_name_eng}

- )} -

{webType.web_type}

-
- - {webType.is_active === "Y" ? "활성화" : "비활성화"} - -
-
-
- 카테고리 - {webType.category} -
-
- 순서 - {webType.sort_order || 0} -
- {webType.description && ( -
- 설명 - {webType.description} -
- )} -
- 수정일 - - {webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"} - -
-
-
- - - - - - - - - - - - - 웹타입 삭제 - - '{webType.type_name}' 웹타입을 삭제하시겠습니까? -
이 작업은 되돌릴 수 없습니다. -
-
- - 취소 - handleDelete(webType.web_type, webType.type_name)} - disabled={isDeleting} - className="bg-destructive hover:bg-destructive/90" - > - {isDeleting ? "삭제 중..." : "삭제"} - - -
-
-
-
- ))} -
- - )} + + data={filteredAndSortedWebTypes} + columns={columns} + keyExtractor={(wt) => wt.web_type} + isLoading={isLoading} + emptyMessage="조건에 맞는 웹타입이 없습니다." + skeletonCount={6} + cardTitle={(wt) => wt.type_name} + cardSubtitle={(wt) => ( + <> + {wt.type_name_eng && ( + {wt.type_name_eng} / + )} + {wt.web_type} + + )} + cardHeaderRight={(wt) => ( + + {wt.is_active === "Y" ? "활성화" : "비활성화"} + + )} + cardFields={cardFields} + actionsLabel="작업" + actionsWidth="140px" + renderActions={(wt) => ( + <> + + + + + + + {renderDeleteDialog(wt)} + + )} + />
); } diff --git a/frontend/app/(main)/admin/system-notices/page.tsx b/frontend/app/(main)/admin/system-notices/page.tsx index c3815de7..56fd99aa 100644 --- a/frontend/app/(main)/admin/system-notices/page.tsx +++ b/frontend/app/(main)/admin/system-notices/page.tsx @@ -22,14 +22,6 @@ import { DialogDescription, DialogFooter, } from "@/components/ui/dialog"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { Plus, Pencil, Trash2, Search, RefreshCw } from "lucide-react"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { @@ -40,15 +32,14 @@ import { updateSystemNotice, deleteSystemNotice, } from "@/lib/api/systemNotice"; +import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView"; -// 우선순위 레이블 반환 function getPriorityLabel(priority: number): { label: string; variant: "default" | "secondary" | "destructive" | "outline" } { if (priority >= 3) return { label: "높음", variant: "destructive" }; if (priority === 2) return { label: "보통", variant: "default" }; return { label: "낮음", variant: "secondary" }; } -// 날짜 포맷 function formatDate(dateStr: string): string { if (!dateStr) return "-"; return new Date(dateStr).toLocaleDateString("ko-KR", { @@ -58,7 +49,6 @@ function formatDate(dateStr: string): string { }); } -// 폼 초기값 const EMPTY_FORM: CreateSystemNoticePayload = { title: "", content: "", @@ -72,21 +62,17 @@ export default function SystemNoticesPage() { const [isLoading, setIsLoading] = useState(true); const [errorMsg, setErrorMsg] = useState(null); - // 검색 필터 const [searchText, setSearchText] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); - // 등록/수정 모달 const [isFormOpen, setIsFormOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); const [formData, setFormData] = useState(EMPTY_FORM); const [isSaving, setIsSaving] = useState(false); - // 삭제 확인 모달 const [deleteTarget, setDeleteTarget] = useState(null); const [isDeleting, setIsDeleting] = useState(false); - // 공지사항 목록 로드 const loadNotices = useCallback(async () => { setIsLoading(true); setErrorMsg(null); @@ -103,7 +89,6 @@ export default function SystemNoticesPage() { loadNotices(); }, [loadNotices]); - // 검색/필터 적용 useEffect(() => { let result = [...notices]; @@ -124,14 +109,12 @@ export default function SystemNoticesPage() { setFilteredNotices(result); }, [notices, searchText, statusFilter]); - // 등록 모달 열기 const handleOpenCreate = () => { setEditTarget(null); setFormData(EMPTY_FORM); setIsFormOpen(true); }; - // 수정 모달 열기 const handleOpenEdit = (notice: SystemNotice) => { setEditTarget(notice); setFormData({ @@ -143,7 +126,6 @@ export default function SystemNoticesPage() { setIsFormOpen(true); }; - // 저장 처리 const handleSave = async () => { if (!formData.title.trim()) { alert("제목을 입력해주세요."); @@ -172,7 +154,6 @@ export default function SystemNoticesPage() { setIsSaving(false); }; - // 삭제 처리 const handleDelete = async () => { if (!deleteTarget) return; setIsDeleting(true); @@ -186,6 +167,64 @@ export default function SystemNoticesPage() { setIsDeleting(false); }; + const columns: RDVColumn[] = [ + { + key: "title", + label: "제목", + render: (_val, notice) => ( + {notice.title} + ), + }, + { + key: "is_active", + label: "상태", + width: "100px", + render: (_val, notice) => ( + + {notice.is_active ? "활성" : "비활성"} + + ), + }, + { + key: "priority", + label: "우선순위", + width: "100px", + render: (_val, notice) => { + const p = getPriorityLabel(notice.priority); + return {p.label}; + }, + }, + { + key: "created_by", + label: "작성자", + width: "120px", + hideOnMobile: true, + render: (_val, notice) => ( + {notice.created_by || "-"} + ), + }, + { + key: "created_at", + label: "작성일", + width: "120px", + hideOnMobile: true, + render: (_val, notice) => ( + {formatDate(notice.created_at)} + ), + }, + ]; + + const cardFields: RDVCardField[] = [ + { + label: "작성자", + render: (notice) => notice.created_by || "-", + }, + { + label: "작성일", + render: (notice) => formatDate(notice.created_at), + }, + ]; + return (
@@ -217,7 +256,6 @@ export default function SystemNoticesPage() { {/* 검색 툴바 */}
- {/* 상태 필터 */}
- {/* 제목 검색 */}
- {/* 데스크톱 테이블 */} -
- - - - 제목 - 상태 - 우선순위 - 작성자 - 작성일 - 관리 - - - - {isLoading ? ( - Array.from({ length: 5 }).map((_, i) => ( - - {Array.from({ length: 6 }).map((_, j) => ( - -
- - ))} - - )) - ) : filteredNotices.length === 0 ? ( - - - 공지사항이 없습니다. - - - ) : ( - filteredNotices.map((notice) => { - const priority = getPriorityLabel(notice.priority); - return ( - - {notice.title} - - - {notice.is_active ? "활성" : "비활성"} - - - - {priority.label} - - - {notice.created_by || "-"} - - - {formatDate(notice.created_at)} - - -
- - -
-
-
- ); - }) - )} - -
-
- - {/* 모바일 카드 뷰 */} -
- {isLoading ? ( - Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
-
-
- )) - ) : filteredNotices.length === 0 ? ( -
- 공지사항이 없습니다. + + data={filteredNotices} + columns={columns} + keyExtractor={(n) => String(n.id)} + isLoading={isLoading} + emptyMessage="공지사항이 없습니다." + skeletonCount={5} + cardTitle={(n) => n.title} + cardHeaderRight={(n) => ( +
+ +
- ) : ( - filteredNotices.map((notice) => { - const priority = getPriorityLabel(notice.priority); - return ( -
-
-

{notice.title}

-
- - -
-
-
- - {notice.is_active ? "활성" : "비활성"} - - {priority.label} -
-
-
- 작성자 - {notice.created_by || "-"} -
-
- 작성일 - {formatDate(notice.created_at)} -
-
-
- ); - }) )} -
+ cardSubtitle={(n) => { + const p = getPriorityLabel(n.priority); + return ( + + + {n.is_active ? "활성" : "비활성"} + + {p.label} + + ); + }} + cardFields={cardFields} + actionsLabel="관리" + actionsWidth="120px" + renderActions={(notice) => ( + <> + + + + )} + />
{/* 등록/수정 모달 */} @@ -422,7 +382,6 @@ export default function SystemNoticesPage() {
- {/* 제목 */}
- {/* 내용 */}
- {/* 우선순위 */}
- {/* 활성 여부 */}
- {/* 데스크톱 테이블 스켈레톤 */} -
- - - - {COMPANY_TABLE_COLUMNS.map((column) => ( - - {column.label} - - ))} - 디스크 사용량 - 작업 - - - - {Array.from({ length: 10 }).map((_, index) => ( - - -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
- ))} -
-
-
+ // 데스크톱 테이블 컬럼 정의 + const columns: RDVColumn[] = [ + { + key: "company_code", + label: "회사코드", + width: "150px", + render: (value) => {value}, + }, + { + key: "company_name", + label: "회사명", + render: (value) => {value}, + }, + { + key: "writer", + label: "등록자", + width: "200px", + }, + { + key: "diskUsage", + label: "디스크 사용량", + hideOnMobile: true, + render: (_value, row) => formatDiskUsage(row), + }, + ]; - {/* 모바일/태블릿 카드 스켈레톤 */} -
- {Array.from({ length: 6 }).map((_, index) => ( -
-
-
-
-
-
-
-
- {Array.from({ length: 3 }).map((_, i) => ( -
-
-
-
- ))} -
-
- ))} -
- - ); - } + // 모바일 카드 필드 정의 + const cardFields: RDVCardField[] = [ + { + label: "작성자", + render: (company) => {company.writer}, + }, + { + label: "디스크 사용량", + render: (company) => formatDiskUsage(company), + }, + ]; - // 데이터가 없을 때 - if (companies.length === 0) { - return ( -
-
-

등록된 회사가 없습니다.

-
-
- ); - } - - // 실제 데이터 렌더링 return ( - <> - {/* 데스크톱 테이블 뷰 (lg 이상) */} -
- - - - {COMPANY_TABLE_COLUMNS.map((column) => ( - - {column.label} - - ))} - 디스크 사용량 - 작업 - - - - {companies.map((company) => ( - - {company.company_code} - {company.company_name} - {company.writer} - {formatDiskUsage(company)} - -
- {/* */} - - -
-
-
- ))} -
-
-
- - {/* 모바일/태블릿 카드 뷰 (lg 미만) */} -
- {companies.map((company) => ( -
+ data={companies} + columns={columns} + keyExtractor={(c) => c.regdate + c.company_code} + isLoading={isLoading} + emptyMessage="등록된 회사가 없습니다." + skeletonCount={10} + cardTitle={(c) => c.company_name} + cardSubtitle={(c) => {c.company_code}} + cardFields={cardFields} + actionsLabel="작업" + actionsWidth="180px" + renderActions={(company) => ( + <> + - - -
-
- ))} -
- + + + + + + )} + /> ); } diff --git a/frontend/components/admin/UserAuthTable.tsx b/frontend/components/admin/UserAuthTable.tsx index c4d0b7c4..50e7d889 100644 --- a/frontend/components/admin/UserAuthTable.tsx +++ b/frontend/components/admin/UserAuthTable.tsx @@ -1,10 +1,10 @@ "use client"; import React from "react"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Shield, ShieldCheck, User as UserIcon, Users, Building2 } from "lucide-react"; +import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView"; interface UserAuthTableProps { users: any[]; @@ -72,158 +72,94 @@ export function UserAuthTable({ users, isLoading, paginationInfo, onEditAuth, on return (paginationInfo.currentPage - 1) * paginationInfo.pageSize + index + 1; }; - // 로딩 스켈레톤 - if (isLoading) { - return ( -
- - - - No - 사용자 ID - 사용자명 - 회사 - 부서 - 현재 권한 - 액션 - - - - {Array.from({ length: 10 }).map((_, index) => ( - - -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- ))} -
-
-
- ); - } + // 데스크톱 테이블 컬럼 정의 + const columns: RDVColumn[] = [ + { + key: "no", + label: "No", + width: "80px", + className: "text-center", + render: (_value, _row, index) => {getRowNumber(index)}, + }, + { + key: "userId", + label: "사용자 ID", + render: (value) => {value}, + }, + { + key: "userName", + label: "사용자명", + }, + { + key: "companyName", + label: "회사", + hideOnMobile: true, + render: (_value, row) => {row.companyName || row.companyCode}, + }, + { + key: "deptName", + label: "부서", + hideOnMobile: true, + render: (value) => {value || "-"}, + }, + { + key: "userType", + label: "현재 권한", + className: "text-center", + render: (_value, row) => { + const typeInfo = getUserTypeInfo(row.userType); + return ( + + {typeInfo.icon} + {typeInfo.label} + + ); + }, + }, + ]; - // 빈 상태 - if (users.length === 0) { - return ( -
-

등록된 사용자가 없습니다.

-
- ); - } + // 모바일 카드 필드 정의 + const cardFields: RDVCardField[] = [ + { + label: "회사", + render: (user) => {user.companyName || user.companyCode}, + }, + { + label: "부서", + render: (user) => {user.deptName || "-"}, + }, + ]; - // 실제 데이터 렌더링 return (
- {/* 데스크톱 테이블 */} -
- - - - No - 사용자 ID - 사용자명 - 회사 - 부서 - 현재 권한 - 액션 - - - - {users.map((user, index) => { - const typeInfo = getUserTypeInfo(user.userType); - return ( - - {getRowNumber(index)} - {user.userId} - {user.userName} - {user.companyName || user.companyCode} - {user.deptName || "-"} - - - {typeInfo.icon} - {typeInfo.label} - - - - - - - ); - })} - -
-
- - {/* 모바일 카드 뷰 */} -
- {users.map((user, index) => { - const typeInfo = getUserTypeInfo(user.userType); + + data={users} + columns={columns} + keyExtractor={(u) => u.userId} + isLoading={isLoading} + emptyMessage="등록된 사용자가 없습니다." + skeletonCount={10} + cardTitle={(u) => u.userName} + cardSubtitle={(u) => {u.userId}} + cardHeaderRight={(u) => { + const typeInfo = getUserTypeInfo(u.userType); return ( -
- {/* 헤더 */} -
-
-

{user.userName}

-

{user.userId}

-
- - {typeInfo.icon} - {typeInfo.label} - -
- - {/* 정보 */} -
-
- 회사 - {user.companyName || user.companyCode} -
-
- 부서 - {user.deptName || "-"} -
-
- - {/* 액션 */} -
- -
-
+ + {typeInfo.icon} + {typeInfo.label} + ); - })} -
+ }} + cardFields={cardFields} + actionsLabel="액션" + actionsWidth="120px" + renderActions={(user) => ( + + )} + /> {/* 페이지네이션 */} {paginationInfo.totalPages > 1 && ( diff --git a/frontend/components/admin/UserTable.tsx b/frontend/components/admin/UserTable.tsx index 1d6d6d46..a0073f4f 100644 --- a/frontend/components/admin/UserTable.tsx +++ b/frontend/components/admin/UserTable.tsx @@ -1,11 +1,10 @@ import { Key, History, Edit } from "lucide-react"; import { useState } from "react"; import { User } from "@/types/user"; -import { USER_TABLE_COLUMNS } from "@/constants/user"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { PaginationInfo } from "@/components/common/Pagination"; +import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView"; import { UserStatusConfirmDialog } from "./UserStatusConfirmDialog"; import { UserHistoryModal } from "./UserHistoryModal"; @@ -59,7 +58,7 @@ export function UserTable({ // 날짜 포맷팅 함수 const formatDate = (dateString: string) => { if (!dateString) return "-"; - return dateString.split(" ")[0]; // "2024-01-15 14:30:00" -> "2024-01-15" + return dateString.split(" ")[0]; }; // 상태 토글 핸들러 (확인 모달 표시) @@ -103,254 +102,190 @@ export function UserTable({ }); }; - // 로딩 상태 렌더링 - if (isLoading) { - return ( - <> - {/* 데스크톱 테이블 스켈레톤 */} -
- - - - {USER_TABLE_COLUMNS.map((column) => ( - - {column.label} - - ))} - 작업 - - - - {Array.from({ length: 10 }).map((_, index) => ( - - {USER_TABLE_COLUMNS.map((column) => ( - -
-
- ))} - -
- {Array.from({ length: 2 }).map((_, i) => ( -
- ))} -
-
-
- ))} -
-
+ // 데스크톱 테이블 컬럼 정의 + const columns: RDVColumn[] = [ + { + key: "no", + label: "No", + width: "60px", + render: (_value, _row, index) => ( + {getRowNumber(index)} + ), + }, + { + key: "sabun", + label: "사번", + width: "80px", + hideOnMobile: true, + render: (value) => {value || "-"}, + }, + { + key: "companyCode", + label: "회사", + width: "120px", + hideOnMobile: true, + render: (value) => {value || "-"}, + }, + { + key: "deptName", + label: "부서명", + width: "120px", + hideOnMobile: true, + render: (value) => {value || "-"}, + }, + { + key: "positionName", + label: "직책", + width: "100px", + hideOnMobile: true, + render: (value) => {value || "-"}, + }, + { + key: "userId", + label: "사용자 ID", + width: "120px", + hideOnMobile: true, + render: (value) => {value}, + }, + { + key: "userName", + label: "사용자명", + width: "100px", + hideOnMobile: true, + render: (value) => {value}, + }, + { + key: "tel", + label: "전화번호", + width: "120px", + hideOnMobile: true, + render: (_value, row) => {row.tel || row.cellPhone || "-"}, + }, + { + key: "email", + label: "이메일", + width: "200px", + hideOnMobile: true, + className: "max-w-[200px] truncate", + render: (value, row) => ( + {value || "-"} + ), + }, + { + key: "regDate", + label: "등록일", + width: "100px", + hideOnMobile: true, + render: (value) => {formatDate(value || "")}, + }, + { + key: "status", + label: "상태", + width: "120px", + hideOnMobile: true, + render: (_value, row) => ( +
+ handleStatusToggle(row, checked)} + aria-label={`${row.userName} 상태 토글`} + />
+ ), + }, + ]; - {/* 모바일/태블릿 카드 스켈레톤 */} -
- {Array.from({ length: 6 }).map((_, index) => ( -
-
-
-
-
-
-
-
-
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
- ))} -
-
-
-
-
-
- ))} -
- - ); - } + // 모바일 카드 필드 정의 + const cardFields: RDVCardField[] = [ + { + label: "사번", + render: (user) => {user.sabun || "-"}, + hideEmpty: true, + }, + { + label: "회사", + render: (user) => {user.companyCode || ""}, + hideEmpty: true, + }, + { + label: "부서", + render: (user) => {user.deptName || ""}, + hideEmpty: true, + }, + { + label: "직책", + render: (user) => {user.positionName || ""}, + hideEmpty: true, + }, + { + label: "연락처", + render: (user) => {user.tel || user.cellPhone || ""}, + hideEmpty: true, + }, + { + label: "이메일", + render: (user) => {user.email || ""}, + hideEmpty: true, + }, + { + label: "등록일", + render: (user) => {formatDate(user.regDate || "")}, + }, + ]; - // 데이터가 없을 때 - if (users.length === 0) { - return ( -
-
-

등록된 사용자가 없습니다.

-
-
- ); - } - - // 실제 데이터 렌더링 return ( <> - {/* 데스크톱 테이블 뷰 (lg 이상) */} -
- - - - {USER_TABLE_COLUMNS.map((column) => ( - - {column.label} - - ))} - 작업 - - - - {users.map((user, index) => ( - - {getRowNumber(index)} - {user.sabun || "-"} - {user.companyCode || "-"} - {user.deptName || "-"} - {user.positionName || "-"} - {user.userId} - {user.userName} - {user.tel || user.cellPhone || "-"} - - {user.email || "-"} - - {formatDate(user.regDate || "")} - -
- handleStatusToggle(user, checked)} - aria-label={`${user.userName} 상태 토글`} - /> -
-
- -
- - - -
-
-
- ))} -
-
-
- - {/* 모바일/태블릿 카드 뷰 (lg 미만) */} -
- {users.map((user, index) => ( -
- {/* 헤더: 이름과 상태 */} -
-
-

{user.userName}

-

{user.userId}

-
- handleStatusToggle(user, checked)} - aria-label={`${user.userName} 상태 토글`} - /> -
- - {/* 정보 그리드 */} -
- {user.sabun && ( -
- 사번 - {user.sabun} -
- )} - {user.companyCode && ( -
- 회사 - {user.companyCode} -
- )} - {user.deptName && ( -
- 부서 - {user.deptName} -
- )} - {user.positionName && ( -
- 직책 - {user.positionName} -
- )} - {(user.tel || user.cellPhone) && ( -
- 연락처 - {user.tel || user.cellPhone} -
- )} - {user.email && ( -
- 이메일 - {user.email} -
- )} -
- 등록일 - {formatDate(user.regDate || "")} -
-
- - {/* 액션 버튼 */} -
- - - -
-
- ))} -
+ + data={users} + columns={columns} + keyExtractor={(u) => u.userId} + isLoading={isLoading} + emptyMessage="등록된 사용자가 없습니다." + skeletonCount={10} + cardTitle={(u) => u.userName || ""} + cardSubtitle={(u) => {u.userId}} + cardHeaderRight={(u) => ( + handleStatusToggle(u, checked)} + aria-label={`${u.userName} 상태 토글`} + /> + )} + cardFields={cardFields} + actionsLabel="작업" + actionsWidth="200px" + renderActions={(user) => ( + <> + + + + + )} + /> {/* 상태 변경 확인 모달 */} (null); - // 노드 플로우 목록 로드 const loadFlows = useCallback(async () => { try { setLoading(true); @@ -68,23 +63,19 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) { } }, []); - // 플로우 목록 로드 useEffect(() => { loadFlows(); }, [loadFlows]); - // 플로우 삭제 const handleDelete = (flow: NodeFlow) => { setSelectedFlow(flow); setShowDeleteModal(true); }; - // 플로우 복사 const handleCopy = async (flow: NodeFlow) => { try { setLoading(true); - // 원본 플로우 데이터 가져오기 const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`); if (!response.data.success) { @@ -93,7 +84,6 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) { const originalFlow = response.data.data; - // 복사본 저장 const copyResponse = await apiClient.post("/dataflow/node-flows", { flowName: `${flow.flowName} (복사본)`, flowDescription: flow.flowDescription, @@ -114,7 +104,6 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) { } }; - // 삭제 확인 const handleConfirmDelete = async () => { if (!selectedFlow) return; @@ -138,18 +127,95 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) { } }; - // 검색 필터링 const filteredFlows = flows.filter( (flow) => flow.flowName.toLowerCase().includes(searchTerm.toLowerCase()) || flow.flowDescription.toLowerCase().includes(searchTerm.toLowerCase()), ); + // DropdownMenu 렌더러 (테이블 + 카드 공통) + const renderDropdownMenu = (flow: NodeFlow) => ( +
e.stopPropagation()}> + + + + + + onLoadFlow(flow.flowId)}> + + 불러오기 + + handleCopy(flow)}> + + 복사 + + handleDelete(flow)} className="text-destructive"> + + 삭제 + + + +
+ ); + + const columns: RDVColumn[] = [ + { + key: "flowName", + label: "플로우명", + render: (_val, flow) => ( +
+ + {flow.flowName} +
+ ), + }, + { + key: "flowDescription", + label: "설명", + render: (_val, flow) => ( + {flow.flowDescription || "설명 없음"} + ), + }, + { + key: "createdAt", + label: "생성일", + render: (_val, flow) => ( + + + {new Date(flow.createdAt).toLocaleDateString()} + + ), + }, + { + key: "updatedAt", + label: "최근 수정", + hideOnMobile: true, + render: (_val, flow) => ( + + + {new Date(flow.updatedAt).toLocaleDateString()} + + ), + }, + ]; + + const cardFields: RDVCardField[] = [ + { + label: "생성일", + render: (flow) => new Date(flow.createdAt).toLocaleDateString(), + }, + { + label: "최근 수정", + render: (flow) => new Date(flow.updatedAt).toLocaleDateString(), + }, + ]; + return (
{/* 검색 및 액션 영역 */}
- {/* 검색 영역 */}
@@ -164,7 +230,6 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
- {/* 액션 버튼 영역 */}
{filteredFlows.length} 건 @@ -176,71 +241,8 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
- {loading ? ( - <> - {/* 데스크톱 테이블 스켈레톤 */} -
- - - - 플로우명 - 설명 - 생성일 - 최근 수정 - 작업 - - - - {Array.from({ length: 5 }).map((_, index) => ( - - -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
- ))} -
-
-
- - {/* 모바일/태블릿 카드 스켈레톤 */} -
- {Array.from({ length: 4 }).map((_, index) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))} -
- - ) : filteredFlows.length === 0 ? ( + {/* 빈 상태: 커스텀 Empty UI */} + {!loading && filteredFlows.length === 0 ? (
@@ -257,135 +259,26 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
) : ( - <> - {/* 데스크톱 테이블 뷰 (lg 이상) */} -
- - - - 플로우명 - 설명 - 생성일 - 최근 수정 - 작업 - - - - {filteredFlows.map((flow) => ( - onLoadFlow(flow.flowId)} - > - -
- - {flow.flowName} -
-
- -
{flow.flowDescription || "설명 없음"}
-
- -
- - {new Date(flow.createdAt).toLocaleDateString()} -
-
- -
- - {new Date(flow.updatedAt).toLocaleDateString()} -
-
- e.stopPropagation()}> -
- - - - - - onLoadFlow(flow.flowId)}> - - 불러오기 - - handleCopy(flow)}> - - 복사 - - handleDelete(flow)} className="text-destructive"> - - 삭제 - - - -
-
-
- ))} -
-
-
- - {/* 모바일/태블릿 카드 뷰 (lg 미만) */} -
- {filteredFlows.map((flow) => ( -
onLoadFlow(flow.flowId)} - > - {/* 헤더 */} -
-
-
- -

{flow.flowName}

-
-

{flow.flowDescription || "설명 없음"}

-
-
e.stopPropagation()}> - - - - - - onLoadFlow(flow.flowId)}> - - 불러오기 - - handleCopy(flow)}> - - 복사 - - handleDelete(flow)} className="text-destructive"> - - 삭제 - - - -
-
- - {/* 정보 */} -
-
- 생성일 - {new Date(flow.createdAt).toLocaleDateString()} -
-
- 최근 수정 - {new Date(flow.updatedAt).toLocaleDateString()} -
-
-
- ))} -
- + + data={filteredFlows} + columns={columns} + keyExtractor={(flow) => String(flow.flowId)} + isLoading={loading} + skeletonCount={5} + cardTitle={(flow) => ( + + + {flow.flowName} + + )} + cardSubtitle={(flow) => flow.flowDescription || "설명 없음"} + cardHeaderRight={renderDropdownMenu} + cardFields={cardFields} + actionsLabel="작업" + actionsWidth="80px" + renderActions={renderDropdownMenu} + onRowClick={(flow) => onLoadFlow(flow.flowId)} + /> )} {/* 삭제 확인 모달 */}