97b333dd2e
부팅 시 자동 시드: - 외부 REST API 연결 6종 (부서/사원/거래처/창고/계정과목/Wehago 사용자) - 매칭 배치 6개 + Wehago HMAC-SHA256 서명 자동 부착 (erpApiClient/erpPresetSeedService/erpBatchSeedService) - 동기화 대상 테이블/컬럼 보장 idempotent 마이그레이션 (erpTableMigration) 배치 기능 확장: - 조건부 매핑 (mapping_type='conditional') — when/then/default 규칙으로 값 변환 (예: enrlFg=J01→active) - 행 단위 제외 필터 (row_filter_config) — 특정 API 필드 값 가진 행 동기화 제외 (예: loginId=wace 통합 ERP 계정 제외) - save_mode/conflict_key 기반 UPSERT, data_array_path 응답 배열 추출 UI 정비: - 배치/플로우/메일/REST API 목록에 페이징 + FullHD 컴팩트 레이아웃 - 배치 편집 화면 한 화면 풀 활용 — TO 패널 가로 그리드, FROM 패널 등록 연결 한 줄 요약, 응답/JSON/파라미터 details 접힘 - ResponsiveDataView/AdminPageRenderer 쿼리 파라미터(?edit=N) 파싱 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import React, { ReactNode } from "react";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// 컬럼 정의 타입
|
|
export interface RDVColumn<T> {
|
|
key: string;
|
|
label: string;
|
|
width?: string;
|
|
render?: (value: any, row: T, index: number) => ReactNode;
|
|
hideOnMobile?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
// 카드 필드 타입
|
|
export interface RDVCardField<T> {
|
|
label: string;
|
|
render: (item: T) => ReactNode;
|
|
hideEmpty?: boolean;
|
|
}
|
|
|
|
// 메인 Props 타입
|
|
export interface ResponsiveDataViewProps<T> {
|
|
data: T[];
|
|
columns: RDVColumn<T>[];
|
|
keyExtractor: (item: T) => string;
|
|
|
|
// 로딩/빈 상태
|
|
isLoading?: boolean;
|
|
emptyMessage?: string;
|
|
skeletonCount?: number;
|
|
|
|
// 카드 설정 (모바일)
|
|
cardTitle: (item: T) => ReactNode;
|
|
cardSubtitle?: (item: T) => ReactNode;
|
|
cardHeaderRight?: (item: T) => ReactNode;
|
|
cardFields?: RDVCardField<T>[] | ((item: T) => RDVCardField<T>[]);
|
|
|
|
// 액션 (테이블 마지막 컬럼 + 카드 하단)
|
|
renderActions?: (item: T) => ReactNode;
|
|
actionsLabel?: string;
|
|
actionsWidth?: string;
|
|
|
|
// 행 클릭
|
|
onRowClick?: (item: T) => void;
|
|
|
|
// 스타일 커스터마이징
|
|
tableContainerClassName?: string;
|
|
cardContainerClassName?: string;
|
|
}
|
|
|
|
// 중첩 객체에서 키 경로로 값을 꺼내는 헬퍼
|
|
function getNestedValue(obj: any, path: string): any {
|
|
return path.split(".").reduce((acc, key) => acc?.[key], obj);
|
|
}
|
|
|
|
export function ResponsiveDataView<T>({
|
|
data,
|
|
columns,
|
|
keyExtractor,
|
|
isLoading = false,
|
|
emptyMessage,
|
|
skeletonCount = 5,
|
|
cardTitle,
|
|
cardSubtitle,
|
|
cardHeaderRight,
|
|
cardFields,
|
|
renderActions,
|
|
actionsLabel,
|
|
actionsWidth,
|
|
onRowClick,
|
|
tableContainerClassName,
|
|
cardContainerClassName,
|
|
}: ResponsiveDataViewProps<T>) {
|
|
// cardFields 미지정 시 columns에서 자동 생성
|
|
function resolveCardFields(item: T): RDVCardField<T>[] {
|
|
if (typeof cardFields === "function") return cardFields(item);
|
|
if (Array.isArray(cardFields)) return cardFields;
|
|
return columns
|
|
.filter((col) => !col.hideOnMobile)
|
|
.map((col) => ({
|
|
label: col.label,
|
|
render: (row: T) =>
|
|
col.render
|
|
? col.render(getNestedValue(row, col.key), row, 0)
|
|
: String(getNestedValue(row, col.key) ?? "-"),
|
|
}));
|
|
}
|
|
|
|
// --- 로딩 스켈레톤 ---
|
|
if (isLoading) {
|
|
return (
|
|
<>
|
|
{/* 데스크톱 테이블 스켈레톤 */}
|
|
<div
|
|
className={cn(
|
|
"hidden rounded-lg border bg-card shadow-sm lg:block",
|
|
tableContainerClassName
|
|
)}
|
|
>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
|
{columns.map((col) => (
|
|
<TableHead
|
|
key={col.key}
|
|
style={col.width ? { width: col.width } : undefined}
|
|
className="h-9 px-3 text-xs font-semibold"
|
|
>
|
|
{col.label}
|
|
</TableHead>
|
|
))}
|
|
{renderActions && (
|
|
<TableHead
|
|
style={{ width: actionsWidth || "120px" }}
|
|
className="h-9 px-3 text-xs font-semibold"
|
|
>
|
|
{actionsLabel || "작업"}
|
|
</TableHead>
|
|
)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{Array.from({ length: skeletonCount }).map((_, rowIdx) => (
|
|
<TableRow key={rowIdx} className="border-b">
|
|
{columns.map((col) => (
|
|
<TableCell key={col.key} className="h-11">
|
|
<div className="h-4 animate-pulse rounded bg-muted" />
|
|
</TableCell>
|
|
))}
|
|
{renderActions && (
|
|
<TableCell className="h-11">
|
|
<div className="flex gap-1.5">
|
|
<div className="h-7 w-7 animate-pulse rounded bg-muted" />
|
|
<div className="h-7 w-7 animate-pulse rounded bg-muted" />
|
|
</div>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* 모바일 카드 스켈레톤 */}
|
|
<div
|
|
className={cn(
|
|
"grid gap-4 sm:grid-cols-2 lg:hidden",
|
|
cardContainerClassName
|
|
)}
|
|
>
|
|
{Array.from({ length: skeletonCount }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="rounded-lg border bg-card p-4 shadow-sm"
|
|
>
|
|
<div className="mb-3 flex items-start justify-between">
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-5 w-32 animate-pulse rounded bg-muted" />
|
|
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
|
</div>
|
|
<div className="h-6 w-12 animate-pulse rounded bg-muted" />
|
|
</div>
|
|
<div className="space-y-2 border-t pt-3">
|
|
{Array.from({ length: 3 }).map((_, j) => (
|
|
<div key={j} className="flex justify-between">
|
|
<div className="h-4 w-16 animate-pulse rounded bg-muted" />
|
|
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
{renderActions && (
|
|
<div className="mt-3 flex gap-2 border-t pt-3">
|
|
<div className="h-9 flex-1 animate-pulse rounded bg-muted" />
|
|
<div className="h-9 flex-1 animate-pulse rounded bg-muted" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// --- 빈 상태 ---
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className="flex h-32 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
|
|
{emptyMessage || "데이터가 없습니다."}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- 실제 데이터 렌더링 ---
|
|
return (
|
|
<>
|
|
{/* 데스크톱 테이블 (lg 이상) */}
|
|
<div
|
|
className={cn(
|
|
"hidden rounded-lg border bg-card shadow-sm lg:block",
|
|
tableContainerClassName
|
|
)}
|
|
>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
|
{columns.map((col) => (
|
|
<TableHead
|
|
key={col.key}
|
|
style={col.width ? { width: col.width } : undefined}
|
|
className="h-9 px-3 text-xs font-semibold"
|
|
>
|
|
{col.label}
|
|
</TableHead>
|
|
))}
|
|
{renderActions && (
|
|
<TableHead
|
|
style={{ width: actionsWidth || "120px" }}
|
|
className="h-9 px-3 text-xs font-semibold"
|
|
>
|
|
{actionsLabel || "작업"}
|
|
</TableHead>
|
|
)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{data.map((item, index) => (
|
|
<TableRow
|
|
key={keyExtractor(item)}
|
|
className={cn(
|
|
"border-b transition-colors hover:bg-muted/50",
|
|
onRowClick && "cursor-pointer"
|
|
)}
|
|
onClick={() => onRowClick?.(item)}
|
|
>
|
|
{columns.map((col) => (
|
|
<TableCell
|
|
key={col.key}
|
|
className={cn("h-11 px-3 py-1.5 text-xs", col.className)}
|
|
>
|
|
{col.render
|
|
? col.render(getNestedValue(item, col.key), item, index)
|
|
: String(getNestedValue(item, col.key) ?? "-")}
|
|
</TableCell>
|
|
))}
|
|
{renderActions && (
|
|
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
|
<div className="flex gap-1.5">{renderActions(item)}</div>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* 모바일 카드 (lg 미만) */}
|
|
<div
|
|
className={cn(
|
|
"grid gap-4 sm:grid-cols-2 lg:hidden",
|
|
cardContainerClassName
|
|
)}
|
|
>
|
|
{data.map((item) => {
|
|
const fields = resolveCardFields(item);
|
|
return (
|
|
<div
|
|
key={keyExtractor(item)}
|
|
className={cn(
|
|
"rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50",
|
|
onRowClick && "cursor-pointer"
|
|
)}
|
|
onClick={() => onRowClick?.(item)}
|
|
>
|
|
{/* 카드 헤더 */}
|
|
<div className="mb-3 flex items-start justify-between">
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="truncate text-base font-semibold">
|
|
{cardTitle(item)}
|
|
</h3>
|
|
{cardSubtitle && (
|
|
<p className="mt-0.5 truncate text-sm text-muted-foreground">
|
|
{cardSubtitle(item)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{cardHeaderRight && (
|
|
<div className="ml-2 shrink-0">{cardHeaderRight(item)}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 카드 필드 */}
|
|
{fields.length > 0 && (
|
|
<div className="space-y-1.5 border-t pt-3">
|
|
{fields.map((field, i) => (
|
|
<div key={i} className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
{field.label}
|
|
</span>
|
|
<span className="font-medium">{field.render(item)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 카드 액션 */}
|
|
{renderActions && (
|
|
<div className="mt-3 flex gap-2 border-t pt-3">
|
|
{renderActions(item)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|