차량 관리 (신규): - VehicleList/VehicleDetail: 차량 목록/상세 - ForkliftList/ForkliftDetail: 지게차 목록/상세 - VehicleLogList/VehicleLogDetail: 운행일지 목록/상세 - 관련 페이지 라우트 추가 (/vehicle-management/*) CEO 대시보드: - Enhanced 섹션 컴포넌트 적용 (아이콘 + 컬러 테마) - EnhancedStatusBoardSection, EnhancedDailyReportSection, EnhancedMonthlyExpenseSection - TodayIssueSection 개선 IntegratedDetailTemplate: - FieldInput, FieldRenderer 기능 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
391 lines
9.9 KiB
TypeScript
391 lines
9.9 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* FieldRenderer - 필드 타입별 렌더링 컴포넌트
|
|
*/
|
|
|
|
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 { PhoneInput } from '@/components/ui/phone-input';
|
|
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
|
import { PersonalNumberInput } from '@/components/ui/personal-number-input';
|
|
import { CurrencyInput } from '@/components/ui/currency-input';
|
|
import { QuantityInput } from '@/components/ui/quantity-input';
|
|
import { cn } from '@/lib/utils';
|
|
import { formatPhoneNumber, formatBusinessNumber, formatNumber } from '@/lib/formatters';
|
|
import type { FieldDefinition, DetailMode, FieldOption } from './types';
|
|
import type { ReactNode } from 'react';
|
|
|
|
interface FieldRendererProps {
|
|
field: FieldDefinition;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
mode: DetailMode;
|
|
error?: string;
|
|
dynamicOptions?: FieldOption[];
|
|
}
|
|
|
|
export function FieldRenderer({
|
|
field,
|
|
value,
|
|
onChange,
|
|
mode,
|
|
error,
|
|
dynamicOptions,
|
|
}: FieldRendererProps) {
|
|
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="space-y-1">
|
|
<Label className="text-sm font-medium text-muted-foreground">
|
|
{field.label}
|
|
</Label>
|
|
<div className="text-sm mt-1">
|
|
{renderViewValue(field, value, options)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Form 모드: 입력 필드
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor={field.key}
|
|
className={cn(field.required && "after:content-['*'] after:ml-0.5 after:text-red-500")}
|
|
>
|
|
{field.label}
|
|
</Label>
|
|
{renderFormField(field, value, onChange, isDisabled, options, error)}
|
|
{field.helpText && (
|
|
<p className="text-xs text-muted-foreground">{field.helpText}</p>
|
|
)}
|
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// View 모드 값 렌더링
|
|
function renderViewValue(
|
|
field: FieldDefinition,
|
|
value: unknown,
|
|
options: FieldOption[]
|
|
): ReactNode {
|
|
// custom 타입이면서 renderField가 있으면 view 모드로 렌더링
|
|
if (field.type === 'custom' && field.renderField) {
|
|
return field.renderField({
|
|
value,
|
|
onChange: () => {}, // view 모드에서는 사용 안됨
|
|
mode: 'view',
|
|
disabled: true,
|
|
});
|
|
}
|
|
|
|
// 커스텀 포맷터가 있으면 사용
|
|
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>
|
|
);
|
|
|
|
case 'phone':
|
|
return formatPhoneNumber(String(value));
|
|
|
|
case 'businessNumber':
|
|
return formatBusinessNumber(String(value));
|
|
|
|
case 'personalNumber': {
|
|
const pn = String(value).replace(/\D/g, '');
|
|
if (pn.length === 13) {
|
|
return `${pn.slice(0, 6)}-${pn.slice(6, 7)}******`;
|
|
}
|
|
return pn;
|
|
}
|
|
|
|
case 'currency':
|
|
return `₩ ${formatNumber(Number(value) || 0, { useComma: true })}`;
|
|
|
|
case 'quantity':
|
|
return formatNumber(Number(value) || 0, { useComma: true });
|
|
|
|
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) : '';
|
|
|
|
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(error && 'border-red-500')}
|
|
/>
|
|
);
|
|
|
|
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(error && 'border-red-500')}
|
|
/>
|
|
);
|
|
|
|
case 'password':
|
|
return (
|
|
<Input
|
|
id={field.key}
|
|
type="password"
|
|
value={stringValue}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={field.placeholder || '****'}
|
|
disabled={disabled}
|
|
className={cn(error && 'border-red-500')}
|
|
/>
|
|
);
|
|
|
|
case 'textarea':
|
|
return (
|
|
<Textarea
|
|
id={field.key}
|
|
value={stringValue}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={field.placeholder}
|
|
disabled={disabled}
|
|
className={cn(error && 'border-red-500')}
|
|
rows={4}
|
|
/>
|
|
);
|
|
|
|
case 'select':
|
|
return (
|
|
<Select
|
|
key={`${field.key}-${stringValue}`}
|
|
value={stringValue}
|
|
onValueChange={onChange}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger className={cn(error && 'border-red-500')}>
|
|
<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(error && 'border-red-500')}
|
|
/>
|
|
);
|
|
|
|
case 'phone':
|
|
return (
|
|
<PhoneInput
|
|
value={stringValue}
|
|
onChange={(v) => onChange(v)}
|
|
placeholder={field.placeholder}
|
|
disabled={disabled}
|
|
error={!!error}
|
|
/>
|
|
);
|
|
|
|
case 'businessNumber':
|
|
return (
|
|
<BusinessNumberInput
|
|
value={stringValue}
|
|
onChange={(v) => onChange(v)}
|
|
placeholder={field.placeholder}
|
|
disabled={disabled}
|
|
error={!!error}
|
|
showValidation
|
|
/>
|
|
);
|
|
|
|
case 'personalNumber':
|
|
return (
|
|
<PersonalNumberInput
|
|
value={stringValue}
|
|
onChange={(v) => onChange(v)}
|
|
placeholder={field.placeholder}
|
|
disabled={disabled}
|
|
error={!!error}
|
|
maskBack
|
|
/>
|
|
);
|
|
|
|
case 'currency':
|
|
return (
|
|
<CurrencyInput
|
|
value={value as number | undefined}
|
|
onChange={(v) => onChange(v)}
|
|
placeholder={field.placeholder}
|
|
disabled={disabled}
|
|
error={!!error}
|
|
/>
|
|
);
|
|
|
|
case 'quantity':
|
|
return (
|
|
<QuantityInput
|
|
value={value as number | undefined}
|
|
onChange={(v) => onChange(v)}
|
|
placeholder={field.placeholder}
|
|
disabled={disabled}
|
|
error={!!error}
|
|
min={0}
|
|
/>
|
|
);
|
|
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
}
|