feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개): - 노무관리, 단가관리(건설), 입금, 출금 Phase 3 라우팅 구조 변경 완료 (22개): - 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A - 현장관리, 실행내역, 견적관리, 견적(테스트) - 입찰관리, 이슈관리, 현장설명회, 견적서(건설) - 협력업체, 시공관리, 기성관리, 품목관리(건설) - 회계 도메인: 거래처, 매출, 세금계산서, 매입 신규 컴포넌트: - ErrorCard: 에러 페이지 UI 통일 - ServerErrorPage: V2 페이지 에러 처리 필수 - V2 Client 컴포넌트 및 Config 파일들 총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
283
src/components/templates/IntegratedDetailTemplate/FieldInput.tsx
Normal file
283
src/components/templates/IntegratedDetailTemplate/FieldInput.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* FieldInput - 순수 입력 컴포넌트 렌더러
|
||||
*
|
||||
* 라벨, 에러 메시지 없이 순수 입력 컴포넌트만 반환
|
||||
* 레이아웃(라벨, 에러, description)은 DetailField가 담당
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FieldDefinition, DetailMode, FieldOption } from './types';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface FieldInputProps {
|
||||
field: FieldDefinition;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
mode: DetailMode;
|
||||
error?: string;
|
||||
dynamicOptions?: FieldOption[];
|
||||
}
|
||||
|
||||
export function FieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
mode,
|
||||
error,
|
||||
dynamicOptions,
|
||||
}: FieldInputProps) {
|
||||
const isViewMode = mode === 'view';
|
||||
const isDisabled =
|
||||
field.readonly ||
|
||||
(typeof field.disabled === 'function'
|
||||
? field.disabled(mode)
|
||||
: field.disabled);
|
||||
|
||||
// 옵션 (동적 로드된 옵션 우선)
|
||||
const options = dynamicOptions || field.options || [];
|
||||
|
||||
// View 모드: 값만 표시 (라벨 없이)
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{renderViewValue(field, value, options)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form 모드: 입력 필드만 반환 (라벨, 에러 없이)
|
||||
return renderFormField(field, value, onChange, isDisabled, options, error);
|
||||
}
|
||||
|
||||
// View 모드 값 렌더링
|
||||
function renderViewValue(
|
||||
field: FieldDefinition,
|
||||
value: unknown,
|
||||
options: FieldOption[]
|
||||
): ReactNode {
|
||||
// 커스텀 포맷터가 있으면 사용
|
||||
if (field.formatValue) {
|
||||
return field.formatValue(value);
|
||||
}
|
||||
|
||||
// 값이 없으면 '-' 표시
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'password':
|
||||
return '****';
|
||||
|
||||
case 'select':
|
||||
case 'radio': {
|
||||
const option = options.find((opt) => opt.value === value);
|
||||
return option?.label || String(value);
|
||||
}
|
||||
|
||||
case 'checkbox':
|
||||
return value ? '예' : '아니오';
|
||||
|
||||
case 'date':
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString('ko-KR');
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="whitespace-pre-wrap">{String(value)}</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Form 모드 필드 렌더링 (입력 컴포넌트만)
|
||||
function renderFormField(
|
||||
field: FieldDefinition,
|
||||
value: unknown,
|
||||
onChange: (value: unknown) => void,
|
||||
disabled: boolean,
|
||||
options: FieldOption[],
|
||||
error?: string
|
||||
): ReactNode {
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
const hasError = !!error;
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'email':
|
||||
case 'tel':
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
type={field.type}
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(hasError && 'border-destructive')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
type="number"
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : '')}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(hasError && 'border-destructive')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'password':
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
type="password"
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder || '****'}
|
||||
disabled={disabled}
|
||||
className={cn(hasError && 'border-destructive')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
id={field.key}
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(hasError && 'border-destructive')}
|
||||
rows={4}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
key={`${field.key}-${stringValue}`}
|
||||
value={stringValue}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={cn(hasError && 'border-destructive')}>
|
||||
<SelectValue placeholder={field.placeholder || '선택하세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case 'radio':
|
||||
return (
|
||||
<RadioGroup
|
||||
value={stringValue}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value} id={`${field.key}-${option.value}`} />
|
||||
<Label
|
||||
htmlFor={`${field.key}-${option.value}`}
|
||||
className="font-normal cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={field.key}
|
||||
checked={!!value}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={field.key}
|
||||
className="font-normal cursor-pointer"
|
||||
>
|
||||
{field.placeholder || '동의'}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
type="date"
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={cn(hasError && 'border-destructive')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'custom':
|
||||
if (field.renderField) {
|
||||
return field.renderField({
|
||||
value,
|
||||
onChange,
|
||||
mode: 'edit',
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
return <div className="text-muted-foreground">커스텀 렌더러가 필요합니다</div>;
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default FieldInput;
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* DetailActions - 상세 페이지 버튼 영역 컴포넌트
|
||||
*
|
||||
* View 모드: 목록으로 | [추가액션] 삭제 | 수정
|
||||
* Form 모드: 취소 | 저장/등록
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailActionsProps {
|
||||
/** 현재 모드 */
|
||||
mode: 'view' | 'edit' | 'create';
|
||||
/** 제출 중 여부 */
|
||||
isSubmitting?: boolean;
|
||||
/** 권한 */
|
||||
permissions?: {
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
};
|
||||
/** 버튼 표시 설정 */
|
||||
showButtons?: {
|
||||
back?: boolean;
|
||||
delete?: boolean;
|
||||
edit?: boolean;
|
||||
};
|
||||
/** 버튼 라벨 */
|
||||
labels?: {
|
||||
back?: string;
|
||||
cancel?: string;
|
||||
delete?: string;
|
||||
edit?: string;
|
||||
submit?: string;
|
||||
};
|
||||
/** 핸들러 */
|
||||
onBack?: () => void;
|
||||
onCancel?: () => void;
|
||||
onDelete?: () => void;
|
||||
onEdit?: () => void;
|
||||
onSubmit?: () => void;
|
||||
/** 추가 액션 (view 모드에서 삭제 버튼 앞에 표시) */
|
||||
extraActions?: ReactNode;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DetailActions({
|
||||
mode,
|
||||
isSubmitting = false,
|
||||
permissions = {},
|
||||
showButtons = {},
|
||||
labels = {},
|
||||
onBack,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onSubmit,
|
||||
extraActions,
|
||||
className,
|
||||
}: DetailActionsProps) {
|
||||
const isViewMode = mode === 'view';
|
||||
const isCreateMode = mode === 'create';
|
||||
|
||||
const {
|
||||
canEdit = true,
|
||||
canDelete = true,
|
||||
} = permissions;
|
||||
|
||||
const {
|
||||
back: showBack = true,
|
||||
delete: showDelete = true,
|
||||
edit: showEdit = true,
|
||||
} = showButtons;
|
||||
|
||||
const {
|
||||
back: backLabel = '목록으로',
|
||||
cancel: cancelLabel = '취소',
|
||||
delete: deleteLabel = '삭제',
|
||||
edit: editLabel = '수정',
|
||||
submit: submitLabel,
|
||||
} = labels;
|
||||
|
||||
// 실제 submit 라벨 (create 모드면 '등록', 아니면 '저장')
|
||||
const actualSubmitLabel = submitLabel || (isCreateMode ? '등록' : '저장');
|
||||
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
{/* 왼쪽: 목록으로 */}
|
||||
{showBack && onBack ? (
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{backLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{/* 오른쪽: 추가액션 + 삭제 + 수정 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
{canDelete && showDelete && onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && showEdit && onEdit && (
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{editLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form 모드 (edit/create)
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
{/* 왼쪽: 취소 */}
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
|
||||
{/* 오른쪽: 저장/등록 */}
|
||||
<Button onClick={onSubmit} disabled={isSubmitting}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{actualSubmitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailActions;
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* DetailField - 상세 페이지 필드 레이아웃 컴포넌트
|
||||
*
|
||||
* 라벨, 필수 마크, 에러 메시지, 설명을 포함한 필드 래퍼
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailFieldProps {
|
||||
/** 필드 라벨 */
|
||||
label: string;
|
||||
/** 필수 여부 (default: false) */
|
||||
required?: boolean;
|
||||
/** 에러 메시지 */
|
||||
error?: string;
|
||||
/** 설명 텍스트 */
|
||||
description?: string;
|
||||
/** 그리드 span (1~4) */
|
||||
colSpan?: 1 | 2 | 3 | 4;
|
||||
/** 필드 내용 (Input, Select 등) */
|
||||
children: ReactNode;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 라벨 숨김 (시각적으로만) */
|
||||
hideLabel?: boolean;
|
||||
/** HTML for 속성 연결용 ID */
|
||||
htmlFor?: string;
|
||||
/** 모드 - view 모드에서는 필수마크/에러/description 숨김 */
|
||||
mode?: 'view' | 'edit' | 'create';
|
||||
}
|
||||
|
||||
// colSpan에 따른 그리드 클래스
|
||||
const colSpanClasses = {
|
||||
1: '',
|
||||
2: 'md:col-span-2',
|
||||
3: 'md:col-span-2 lg:col-span-3',
|
||||
4: 'md:col-span-2 lg:col-span-4',
|
||||
};
|
||||
|
||||
export function DetailField({
|
||||
label,
|
||||
required = false,
|
||||
error,
|
||||
description,
|
||||
colSpan = 1,
|
||||
children,
|
||||
className,
|
||||
hideLabel = false,
|
||||
htmlFor,
|
||||
mode,
|
||||
}: DetailFieldProps) {
|
||||
const isViewMode = mode === 'view';
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', colSpanClasses[colSpan], className)}>
|
||||
{/* 라벨 영역 */}
|
||||
<Label
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
isViewMode && 'text-muted-foreground',
|
||||
hideLabel && 'sr-only'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{/* View 모드에서는 필수마크 숨김 */}
|
||||
{required && !isViewMode && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
|
||||
{/* 필드 내용 */}
|
||||
{children}
|
||||
|
||||
{/* 에러 메시지 - View 모드에서는 숨김 */}
|
||||
{error && !isViewMode && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* 설명 텍스트 - View 모드에서는 숨김 */}
|
||||
{description && !error && !isViewMode && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailField;
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* DetailGrid - 상세 페이지 반응형 그리드 컴포넌트
|
||||
*
|
||||
* 1~4열 반응형 그리드 레이아웃 제공
|
||||
* 모바일에서는 자동으로 1열로 변환
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailGridProps {
|
||||
/** 그리드 열 수 (default: 2) */
|
||||
cols?: 1 | 2 | 3 | 4;
|
||||
/** 그리드 간격 (default: 'md') */
|
||||
gap?: 'sm' | 'md' | 'lg';
|
||||
/** 그리드 내용 */
|
||||
children: ReactNode;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 열 수에 따른 그리드 클래스
|
||||
const colsClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// 간격에 따른 gap 클래스
|
||||
const gapClasses = {
|
||||
sm: 'gap-4',
|
||||
md: 'gap-6',
|
||||
lg: 'gap-8',
|
||||
};
|
||||
|
||||
export function DetailGrid({
|
||||
cols = 2,
|
||||
gap = 'md',
|
||||
children,
|
||||
className,
|
||||
}: DetailGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid',
|
||||
colsClasses[cols],
|
||||
gapClasses[gap],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailGrid;
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* DetailSection - 상세 페이지 섹션 래퍼 컴포넌트
|
||||
*
|
||||
* Card 기반의 섹션 컨테이너로 제목, 설명, 접기/펼치기 기능 제공
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailSectionProps {
|
||||
/** 섹션 제목 */
|
||||
title: string;
|
||||
/** 섹션 설명 (선택) */
|
||||
description?: string;
|
||||
/** 섹션 내용 */
|
||||
children: ReactNode;
|
||||
/** 접기/펼치기 가능 여부 (default: false) */
|
||||
collapsible?: boolean;
|
||||
/** 기본 펼침 상태 (default: true) */
|
||||
defaultOpen?: boolean;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 헤더 우측 액션 영역 */
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
export function DetailSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
className,
|
||||
headerActions,
|
||||
}: DetailSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (collapsible) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('', className)}>
|
||||
<CardHeader
|
||||
className={cn(
|
||||
'pb-4',
|
||||
collapsible && 'cursor-pointer select-none'
|
||||
)}
|
||||
onClick={collapsible ? handleToggle : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{title}
|
||||
{collapsible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle();
|
||||
}}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{headerActions && (
|
||||
<div onClick={(e) => e.stopPropagation()}>{headerActions}</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{(!collapsible || isOpen) && (
|
||||
<CardContent className="pt-0">{children}</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailSection;
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* IntegratedDetailTemplate Components Index
|
||||
*
|
||||
* 상세 페이지 내부 컴포넌트 통합 export
|
||||
*/
|
||||
|
||||
// 메인 컴포넌트
|
||||
export { DetailSection, type DetailSectionProps } from './DetailSection';
|
||||
export { DetailGrid, type DetailGridProps } from './DetailGrid';
|
||||
export { DetailField, type DetailFieldProps } from './DetailField';
|
||||
export { DetailActions, type DetailActionsProps } from './DetailActions';
|
||||
|
||||
// 스켈레톤 컴포넌트
|
||||
export {
|
||||
DetailFieldSkeleton,
|
||||
DetailGridSkeleton,
|
||||
DetailSectionSkeleton,
|
||||
type DetailFieldSkeletonProps,
|
||||
type DetailGridSkeletonProps,
|
||||
type DetailSectionSkeletonProps,
|
||||
} from './skeletons';
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* DetailFieldSkeleton - 필드 로딩 스켈레톤 컴포넌트
|
||||
*
|
||||
* 개별 필드의 로딩 상태를 표시
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailFieldSkeletonProps {
|
||||
/** 그리드 span (1~4) */
|
||||
colSpan?: 1 | 2 | 3 | 4;
|
||||
/** 라벨 너비 (default: 'w-20') */
|
||||
labelWidth?: string;
|
||||
/** 입력 높이 (default: 'h-10') */
|
||||
inputHeight?: string;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// colSpan에 따른 그리드 클래스
|
||||
const colSpanClasses = {
|
||||
1: '',
|
||||
2: 'md:col-span-2',
|
||||
3: 'md:col-span-2 lg:col-span-3',
|
||||
4: 'md:col-span-2 lg:col-span-4',
|
||||
};
|
||||
|
||||
export function DetailFieldSkeleton({
|
||||
colSpan = 1,
|
||||
labelWidth = 'w-20',
|
||||
inputHeight = 'h-10',
|
||||
className,
|
||||
}: DetailFieldSkeletonProps) {
|
||||
return (
|
||||
<div className={cn('space-y-2', colSpanClasses[colSpan], className)}>
|
||||
{/* 라벨 스켈레톤 */}
|
||||
<Skeleton className={cn('h-4', labelWidth)} />
|
||||
{/* 입력 필드 스켈레톤 */}
|
||||
<Skeleton className={cn('w-full', inputHeight)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailFieldSkeleton;
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* DetailGridSkeleton - 그리드 로딩 스켈레톤 컴포넌트
|
||||
*
|
||||
* 여러 필드의 그리드 로딩 상태를 표시
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DetailFieldSkeleton } from './DetailFieldSkeleton';
|
||||
|
||||
export interface DetailGridSkeletonProps {
|
||||
/** 그리드 열 수 (default: 2) */
|
||||
cols?: 1 | 2 | 3 | 4;
|
||||
/** 필드 개수 (default: 6) */
|
||||
fieldCount?: number;
|
||||
/** 그리드 간격 (default: 'md') */
|
||||
gap?: 'sm' | 'md' | 'lg';
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 열 수에 따른 그리드 클래스
|
||||
const colsClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// 간격에 따른 gap 클래스
|
||||
const gapClasses = {
|
||||
sm: 'gap-4',
|
||||
md: 'gap-6',
|
||||
lg: 'gap-8',
|
||||
};
|
||||
|
||||
export function DetailGridSkeleton({
|
||||
cols = 2,
|
||||
fieldCount = 6,
|
||||
gap = 'md',
|
||||
className,
|
||||
}: DetailGridSkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid',
|
||||
colsClasses[cols],
|
||||
gapClasses[gap],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: fieldCount }).map((_, index) => (
|
||||
<DetailFieldSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailGridSkeleton;
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* DetailSectionSkeleton - 섹션 로딩 스켈레톤 컴포넌트
|
||||
*
|
||||
* Card 기반의 섹션 로딩 상태를 표시
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DetailGridSkeleton } from './DetailGridSkeleton';
|
||||
|
||||
export interface DetailSectionSkeletonProps {
|
||||
/** 제목 영역 표시 여부 (default: true) */
|
||||
hasTitle?: boolean;
|
||||
/** 커스텀 내용 (없으면 DetailGridSkeleton 사용) */
|
||||
children?: ReactNode;
|
||||
/** 그리드 열 수 (children이 없을 때 사용) */
|
||||
cols?: 1 | 2 | 3 | 4;
|
||||
/** 필드 개수 (children이 없을 때 사용) */
|
||||
fieldCount?: number;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DetailSectionSkeleton({
|
||||
hasTitle = true,
|
||||
children,
|
||||
cols = 2,
|
||||
fieldCount = 6,
|
||||
className,
|
||||
}: DetailSectionSkeletonProps) {
|
||||
return (
|
||||
<Card className={cn('', className)}>
|
||||
{hasTitle && (
|
||||
<CardHeader className="pb-4">
|
||||
{/* 제목 스켈레톤 */}
|
||||
<Skeleton className="h-5 w-1/4" />
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={hasTitle ? 'pt-0' : ''}>
|
||||
{children || (
|
||||
<DetailGridSkeleton cols={cols} fieldCount={fieldCount} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailSectionSkeleton;
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Skeleton Components Index
|
||||
*
|
||||
* 상세 페이지 로딩 상태용 스켈레톤 컴포넌트
|
||||
*/
|
||||
|
||||
export { DetailFieldSkeleton, type DetailFieldSkeletonProps } from './DetailFieldSkeleton';
|
||||
export { DetailGridSkeleton, type DetailGridSkeletonProps } from './DetailGridSkeleton';
|
||||
export { DetailSectionSkeleton, type DetailSectionSkeletonProps } from './DetailSectionSkeleton';
|
||||
@@ -11,9 +11,6 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -26,10 +23,9 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FieldRenderer } from './FieldRenderer';
|
||||
import { FieldInput } from './FieldInput';
|
||||
import { DetailSection, DetailGrid, DetailField, DetailActions, DetailSectionSkeleton } from './components';
|
||||
import type {
|
||||
IntegratedDetailTemplateProps,
|
||||
DetailMode,
|
||||
@@ -54,6 +50,7 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
headerActions,
|
||||
beforeContent,
|
||||
afterContent,
|
||||
buttonPosition = 'bottom',
|
||||
}: IntegratedDetailTemplateProps<T>) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
@@ -266,6 +263,67 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
const actions = config.actions || {};
|
||||
const deleteConfirm = actions.deleteConfirmMessage || {};
|
||||
|
||||
// ===== 버튼 위치 =====
|
||||
const isTopButtons = buttonPosition === 'top';
|
||||
|
||||
// ===== 액션 버튼 렌더링 헬퍼 =====
|
||||
const renderActionButtons = useCallback((additionalClass?: string) => {
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<DetailActions
|
||||
mode="view"
|
||||
permissions={permissions}
|
||||
showButtons={{
|
||||
back: actions.showBack !== false,
|
||||
delete: actions.showDelete !== false && !!onDelete,
|
||||
edit: actions.showEdit !== false,
|
||||
}}
|
||||
labels={{
|
||||
back: actions.backLabel,
|
||||
delete: actions.deleteLabel,
|
||||
edit: actions.editLabel,
|
||||
}}
|
||||
onBack={navigateToList}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
extraActions={headerActions}
|
||||
className={additionalClass}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Form 모드 (edit/create)
|
||||
return (
|
||||
<DetailActions
|
||||
mode={mode}
|
||||
isSubmitting={isSubmitting}
|
||||
permissions={permissions}
|
||||
showButtons={{
|
||||
back: actions.showBack !== false,
|
||||
delete: actions.showDelete !== false && !!onDelete,
|
||||
edit: actions.showEdit !== false,
|
||||
}}
|
||||
labels={{
|
||||
back: actions.backLabel,
|
||||
cancel: actions.cancelLabel,
|
||||
delete: actions.deleteLabel,
|
||||
edit: actions.editLabel,
|
||||
submit: actions.submitLabel,
|
||||
}}
|
||||
onBack={navigateToList}
|
||||
onCancel={handleCancel}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onSubmit={handleSubmit}
|
||||
extraActions={headerActions}
|
||||
className={additionalClass}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
isViewMode, mode, isSubmitting, permissions, actions, headerActions,
|
||||
navigateToList, handleDelete, handleEdit, handleCancel, handleSubmit, onDelete
|
||||
]);
|
||||
|
||||
// ===== 필터링된 필드 =====
|
||||
const visibleFields = useMemo(() => {
|
||||
return config.fields.filter((field) => {
|
||||
@@ -275,13 +333,8 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
});
|
||||
}, [config.fields, isViewMode]);
|
||||
|
||||
// ===== 그리드 클래스 =====
|
||||
// ===== 그리드 컬럼 수 =====
|
||||
const gridCols = config.gridColumns || 2;
|
||||
const gridClass = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
}[gridCols];
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
if (isLoading) {
|
||||
@@ -291,22 +344,10 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
title={config.title}
|
||||
description={config.description}
|
||||
icon={config.icon}
|
||||
actions={isTopButtons ? renderActionButtons() : undefined}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<div className={cn('grid gap-6', gridClass)}>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DetailSectionSkeleton cols={gridCols} fieldCount={6} />
|
||||
{!isTopButtons && renderActionButtons('mt-6')}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -319,36 +360,13 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
title={config.title}
|
||||
description={config.description}
|
||||
icon={config.icon}
|
||||
actions={isTopButtons ? renderActionButtons() : undefined}
|
||||
/>
|
||||
{beforeContent}
|
||||
{renderView(initialData)}
|
||||
{afterContent}
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<Button variant="outline" onClick={navigateToList}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{actions.backLabel || '목록으로'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{headerActions}
|
||||
{permissions.canDelete && onDelete && (actions.showDelete !== false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{actions.deleteLabel || '삭제'}
|
||||
</Button>
|
||||
)}
|
||||
{permissions.canEdit && (actions.showEdit !== false) && (
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{actions.editLabel || '수정'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 버튼 영역 - 하단 배치 시만 */}
|
||||
{!isTopButtons && renderActionButtons('mt-6')}
|
||||
<DeleteDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
@@ -368,6 +386,7 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
title={isCreateMode ? `${config.title} 등록` : `${config.title} 수정`}
|
||||
description={config.description}
|
||||
icon={config.icon}
|
||||
actions={isTopButtons ? renderActionButtons() : undefined}
|
||||
/>
|
||||
{beforeContent}
|
||||
{renderForm({
|
||||
@@ -377,17 +396,8 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
errors,
|
||||
})}
|
||||
{afterContent}
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isSubmitting}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{actions.cancelLabel || '취소'}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isCreateMode ? (actions.submitLabel || '등록') : (actions.submitLabel || '저장')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* 버튼 영역 - 하단 배치 시만 */}
|
||||
{!isTopButtons && renderActionButtons('mt-6')}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -405,6 +415,7 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
}
|
||||
description={config.description}
|
||||
icon={config.icon}
|
||||
actions={isTopButtons ? renderActionButtons() : undefined}
|
||||
/>
|
||||
|
||||
{beforeContent}
|
||||
@@ -413,80 +424,34 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
{/* 섹션이 있으면 섹션별로, 없으면 단일 카드 */}
|
||||
{config.sections && config.sections.length > 0 ? (
|
||||
config.sections.map((section) => (
|
||||
<Card key={section.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{section.title}</CardTitle>
|
||||
{section.description && (
|
||||
<p className="text-sm text-muted-foreground">{section.description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={cn('grid gap-6', gridClass)}>
|
||||
{section.fields.map((fieldKey) => {
|
||||
const field = visibleFields.find((f) => f.key === fieldKey);
|
||||
if (!field) return null;
|
||||
return renderFieldItem(field);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DetailSection
|
||||
key={section.id}
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
collapsible={section.collapsible}
|
||||
defaultOpen={!section.defaultCollapsed}
|
||||
>
|
||||
<DetailGrid cols={gridCols}>
|
||||
{section.fields.map((fieldKey) => {
|
||||
const field = visibleFields.find((f) => f.key === fieldKey);
|
||||
if (!field) return null;
|
||||
return renderFieldItem(field);
|
||||
})}
|
||||
</DetailGrid>
|
||||
</DetailSection>
|
||||
))
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={cn('grid gap-6', gridClass)}>
|
||||
{visibleFields.map((field) => renderFieldItem(field))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DetailSection title="기본 정보">
|
||||
<DetailGrid cols={gridCols}>
|
||||
{visibleFields.map((field) => renderFieldItem(field))}
|
||||
</DetailGrid>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{afterContent}
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={navigateToList}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{actions.backLabel || '목록으로'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{headerActions}
|
||||
{permissions.canDelete && onDelete && (actions.showDelete !== false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{actions.deleteLabel || '삭제'}
|
||||
</Button>
|
||||
)}
|
||||
{permissions.canEdit && (actions.showEdit !== false) && (
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{actions.editLabel || '수정'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isSubmitting}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{actions.cancelLabel || '취소'}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isCreateMode ? (actions.submitLabel || '등록') : (actions.submitLabel || '저장')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* 버튼 영역 - 하단 배치 시만 */}
|
||||
{!isTopButtons && renderActionButtons()}
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
@@ -502,17 +467,14 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
|
||||
// ===== 필드 아이템 렌더링 헬퍼 =====
|
||||
function renderFieldItem(field: FieldDefinition) {
|
||||
const spanClass = {
|
||||
1: '',
|
||||
2: 'md:col-span-2',
|
||||
3: 'md:col-span-2 lg:col-span-3',
|
||||
}[field.gridSpan || 1];
|
||||
// gridSpan을 colSpan으로 매핑 (1, 2, 3, 4만 허용)
|
||||
const colSpan = (field.gridSpan || 1) as 1 | 2 | 3 | 4;
|
||||
|
||||
// 커스텀 필드 렌더러 체크
|
||||
if (renderField) {
|
||||
const customRender = renderField(field, {
|
||||
value: formData[field.key],
|
||||
onChange: (value) => handleChange(field.key, value),
|
||||
onChange: (value: unknown) => handleChange(field.key, value),
|
||||
mode,
|
||||
disabled:
|
||||
field.readonly ||
|
||||
@@ -521,16 +483,34 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
});
|
||||
if (customRender !== null) {
|
||||
return (
|
||||
<div key={field.key} className={spanClass}>
|
||||
<DetailField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
required={field.required}
|
||||
error={errors[field.key]}
|
||||
description={field.helpText}
|
||||
colSpan={colSpan}
|
||||
htmlFor={field.key}
|
||||
mode={mode}
|
||||
>
|
||||
{customRender}
|
||||
</div>
|
||||
</DetailField>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.key} className={spanClass}>
|
||||
<FieldRenderer
|
||||
<DetailField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
required={field.required}
|
||||
error={errors[field.key]}
|
||||
description={field.helpText}
|
||||
colSpan={colSpan}
|
||||
htmlFor={field.key}
|
||||
mode={mode}
|
||||
>
|
||||
<FieldInput
|
||||
field={field}
|
||||
value={formData[field.key]}
|
||||
onChange={(value) => handleChange(field.key, value)}
|
||||
@@ -538,7 +518,7 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
error={errors[field.key]}
|
||||
dynamicOptions={dynamicOptions[field.key]}
|
||||
/>
|
||||
</div>
|
||||
</DetailField>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -611,3 +591,24 @@ function validateRule(
|
||||
|
||||
// Re-export types
|
||||
export * from './types';
|
||||
|
||||
// Re-export FieldInput
|
||||
export { FieldInput, type FieldInputProps } from './FieldInput';
|
||||
|
||||
// Re-export internal components
|
||||
export {
|
||||
DetailSection,
|
||||
DetailGrid,
|
||||
DetailField,
|
||||
DetailActions,
|
||||
DetailFieldSkeleton,
|
||||
DetailGridSkeleton,
|
||||
DetailSectionSkeleton,
|
||||
type DetailSectionProps,
|
||||
type DetailGridProps,
|
||||
type DetailFieldProps,
|
||||
type DetailActionsProps,
|
||||
type DetailFieldSkeletonProps,
|
||||
type DetailGridSkeletonProps,
|
||||
type DetailSectionSkeletonProps,
|
||||
} from './components';
|
||||
|
||||
@@ -64,8 +64,8 @@ export interface FieldDefinition {
|
||||
placeholder?: string;
|
||||
/** 유효성 검사 규칙 */
|
||||
validation?: ValidationRule[];
|
||||
/** 그리드 span (1, 2, 3) - 기본값 1 */
|
||||
gridSpan?: 1 | 2 | 3;
|
||||
/** 그리드 span (1, 2, 3, 4) - 기본값 1, DetailField의 colSpan으로 매핑됨 */
|
||||
gridSpan?: 1 | 2 | 3 | 4;
|
||||
/** view 모드에서 숨김 */
|
||||
hideInView?: boolean;
|
||||
/** form 모드에서 숨김 */
|
||||
@@ -208,6 +208,8 @@ export interface IntegratedDetailTemplateProps<T = Record<string, unknown>> {
|
||||
beforeContent?: ReactNode;
|
||||
/** 폼 뒤에 추가 콘텐츠 */
|
||||
afterContent?: ReactNode;
|
||||
/** 버튼 위치 (기본값: 'bottom') */
|
||||
buttonPosition?: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
|
||||
Reference in New Issue
Block a user