Files
sam-react-prod/src/components/templates/IntegratedDetailTemplate/FieldRenderer.tsx
유병철 e5f0f5da61 feat(WEB): 차량 관리 기능 추가 및 CEO 대시보드 Enhanced 섹션 적용
차량 관리 (신규):
- 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>
2026-01-28 14:53:20 +09:00

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}
/>
);
}
}