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) => (
+ <>
+
- ))}
-
- >
+
+
+ onEdit(company)}
+ className="h-8 w-8"
+ aria-label="수정"
+ >
+
+
+ onDelete(company)}
+ className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
+ aria-label="삭제"
+ >
+
+
+ >
+ )}
+ />
);
}
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}
-
-
-
- onEditAuth(user)} className="h-8 gap-1 text-sm">
-
- 권한 변경
-
-
-
- );
- })}
-
-
-
-
- {/* 모바일 카드 뷰 */}
-
- {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 || "-"}
-
-
-
- {/* 액션 */}
-
- onEditAuth(user)}
- className="h-9 w-full gap-2 text-sm"
- >
-
- 권한 변경
-
-
-
+
+ {typeInfo.icon}
+ {typeInfo.label}
+
);
- })}
-
+ }}
+ cardFields={cardFields}
+ actionsLabel="액션"
+ actionsWidth="120px"
+ renderActions={(user) => (
+
onEditAuth(user)} className="h-8 gap-1 text-sm">
+
+ 권한 변경
+
+ )}
+ />
{/* 페이지네이션 */}
{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} 상태 토글`}
- />
-
-
-
-
- onEdit(user)}
- className="h-8 w-8"
- title="사용자 정보 수정"
- >
-
-
- onPasswordReset(user.userId, user.userName || user.userId)}
- className="h-8 w-8"
- title="비밀번호 초기화"
- >
-
-
- handleOpenHistoryModal(user)}
- className="h-8 w-8"
- title="변경이력 조회"
- >
-
-
-
-
-
- ))}
-
-
-
-
- {/* 모바일/태블릿 카드 뷰 (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 || "")}
-
-
-
- {/* 액션 버튼 */}
-
- onEdit(user)} className="h-9 flex-1 gap-2 text-sm">
-
- 수정
-
- onPasswordReset(user.userId, user.userName || user.userId)}
- className="h-9 w-9 p-0"
- title="비밀번호 초기화"
- >
-
-
- handleOpenHistoryModal(user)}
- className="h-9 w-9 p-0"
- title="변경이력"
- >
-
-
-
-
- ))}
-
+
+ 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) => (
+ <>
+ onEdit(user)}
+ className="h-8 w-8"
+ title="사용자 정보 수정"
+ >
+
+
+ onPasswordReset(user.userId, user.userName || user.userId)}
+ className="h-8 w-8"
+ title="비밀번호 초기화"
+ >
+
+
+ handleOpenHistoryModal(user)}
+ className="h-8 w-8"
+ title="변경이력 조회"
+ >
+
+
+ >
+ )}
+ />
{/* 상태 변경 확인 모달 */}
(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)}
+ />
)}
{/* 삭제 확인 모달 */}