DatePicker 공통화: - date-picker.tsx 공통 컴포넌트 신규 추가 - 전체 폼 컴포넌트 DatePicker 통일 적용 (50+ 파일) - DateRangeSelector 개선 공정관리: - RuleModal 대폭 리팩토링 (-592줄 → 간소화) - ProcessForm, StepForm 개선 - ProcessDetail 수정, actions 확장 작업자화면: - WorkerScreen 기능 대폭 확장 (+543줄) - WorkItemCard 개선 - types 확장 회계/인사/영업/품질: - BadDebtDetail, BillDetail, DepositDetail, SalesDetail 등 DatePicker 적용 - EmployeeForm, VacationDialog 등 DatePicker 적용 - OrderRegistration, QuoteRegistration DatePicker 적용 - InspectionCreate, InspectionDetail DatePicker 적용 공사관리/CEO대시보드: - BiddingDetail, ContractDetail, HandoverReport 등 DatePicker 적용 - ScheduleDetailModal, TodayIssueSection 개선 기타: - WorkOrderCreate/Edit/Detail/List 개선 - ShipmentCreate/Edit, ReceivingDetail 개선 - calendar, calendarEvents 수정 - datepicker 마이그레이션 체크리스트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
296 lines
7.7 KiB
TypeScript
296 lines
7.7 KiB
TypeScript
/**
|
|
* FormField - 통합 폼 필드 컴포넌트
|
|
*/
|
|
|
|
import { ReactNode } from "react";
|
|
import { Label } from "../ui/label";
|
|
import { Input } from "../ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
|
import { Textarea } from "../ui/textarea";
|
|
import { AlertCircle } from "lucide-react";
|
|
import { PhoneInput } from "../ui/phone-input";
|
|
import { BusinessNumberInput } from "../ui/business-number-input";
|
|
import { PersonalNumberInput } from "../ui/personal-number-input";
|
|
import { NumberInput } from "../ui/number-input";
|
|
import { CurrencyInput } from "../ui/currency-input";
|
|
import { QuantityInput } from "../ui/quantity-input";
|
|
import { DatePicker } from "../ui/date-picker";
|
|
|
|
export type FormFieldType =
|
|
| 'text'
|
|
| 'number'
|
|
| 'date'
|
|
| 'select'
|
|
| 'textarea'
|
|
| 'custom'
|
|
| 'password'
|
|
// 새 입력 타입
|
|
| 'phone' // 전화번호 (자동 하이픈)
|
|
| 'businessNumber' // 사업자번호 (000-00-00000)
|
|
| 'personalNumber' // 주민번호 (000000-0000000)
|
|
| 'currency' // 금액 (천단위 콤마, ₩)
|
|
| 'quantity'; // 수량 (정수, 최소 0)
|
|
|
|
export interface SelectOption {
|
|
value: string;
|
|
label: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface FormFieldProps {
|
|
label: string;
|
|
required?: boolean;
|
|
type?: FormFieldType;
|
|
value?: string | number;
|
|
onChange?: (value: string) => void;
|
|
/** number 타입 전용 onChange (currency, quantity 타입에서 사용) */
|
|
onChangeNumber?: (value: number | undefined) => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
error?: string;
|
|
helpText?: string;
|
|
options?: SelectOption[];
|
|
selectPlaceholder?: string;
|
|
children?: ReactNode;
|
|
className?: string;
|
|
inputClassName?: string;
|
|
rows?: number;
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
htmlFor?: string;
|
|
// 새 입력 타입 전용 옵션
|
|
/** 사업자번호 유효성 검사 표시 */
|
|
showValidation?: boolean;
|
|
/** 주민번호 뒷자리 마스킹 */
|
|
maskBack?: boolean;
|
|
/** 소수점 허용 (NumberInput) */
|
|
allowDecimal?: boolean;
|
|
/** 소수점 자릿수 (NumberInput) */
|
|
decimalPlaces?: number;
|
|
/** 천단위 콤마 (NumberInput) */
|
|
useComma?: boolean;
|
|
/** 접미사 (원, 개, % 등) */
|
|
suffix?: string;
|
|
/** 수량 +/- 버튼 표시 */
|
|
showButtons?: boolean;
|
|
}
|
|
|
|
export function FormField({
|
|
label,
|
|
required = false,
|
|
type = 'text',
|
|
value,
|
|
onChange,
|
|
onChangeNumber,
|
|
placeholder,
|
|
disabled = false,
|
|
error,
|
|
helpText,
|
|
options = [],
|
|
selectPlaceholder = "선택하세요",
|
|
children,
|
|
className = "",
|
|
inputClassName = "",
|
|
rows = 3,
|
|
min,
|
|
max,
|
|
step,
|
|
htmlFor,
|
|
// 새 입력 타입 전용 옵션
|
|
showValidation,
|
|
maskBack,
|
|
allowDecimal,
|
|
decimalPlaces,
|
|
useComma,
|
|
suffix,
|
|
showButtons,
|
|
}: FormFieldProps) {
|
|
|
|
const renderInput = () => {
|
|
switch (type) {
|
|
case 'select':
|
|
return (
|
|
<Select
|
|
value={value as string}
|
|
onValueChange={onChange}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger className={`${error ? 'border-red-500' : ''} ${inputClassName}`}>
|
|
<SelectValue placeholder={selectPlaceholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={option.disabled}
|
|
>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
case 'textarea':
|
|
return (
|
|
<Textarea
|
|
id={htmlFor}
|
|
value={value as string}
|
|
onChange={(e) => onChange?.(e.target.value)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
rows={rows}
|
|
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
|
|
/>
|
|
);
|
|
|
|
case 'custom':
|
|
return children;
|
|
|
|
case 'number':
|
|
return (
|
|
<Input
|
|
id={htmlFor}
|
|
type="number"
|
|
value={value}
|
|
onChange={(e) => onChange?.(e.target.value)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
|
|
/>
|
|
);
|
|
|
|
case 'date':
|
|
return (
|
|
<DatePicker
|
|
value={(value as string) || ''}
|
|
onChange={(date) => onChange?.(date)}
|
|
disabled={disabled}
|
|
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
|
|
/>
|
|
);
|
|
|
|
case 'password':
|
|
return (
|
|
<Input
|
|
id={htmlFor}
|
|
type="password"
|
|
value={value as string}
|
|
onChange={(e) => onChange?.(e.target.value)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
|
|
/>
|
|
);
|
|
|
|
case 'phone':
|
|
return (
|
|
<PhoneInput
|
|
value={(value as string) || ''}
|
|
onChange={(v) => onChange?.(v)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
error={!!error}
|
|
className={inputClassName}
|
|
/>
|
|
);
|
|
|
|
case 'businessNumber':
|
|
return (
|
|
<BusinessNumberInput
|
|
value={(value as string) || ''}
|
|
onChange={(v) => onChange?.(v)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
error={!!error}
|
|
showValidation={showValidation}
|
|
className={inputClassName}
|
|
/>
|
|
);
|
|
|
|
case 'personalNumber':
|
|
return (
|
|
<PersonalNumberInput
|
|
value={(value as string) || ''}
|
|
onChange={(v) => onChange?.(v)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
error={!!error}
|
|
maskBack={maskBack}
|
|
className={inputClassName}
|
|
/>
|
|
);
|
|
|
|
case 'currency':
|
|
return (
|
|
<CurrencyInput
|
|
value={value}
|
|
onChange={(v) => onChangeNumber?.(v)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
error={!!error}
|
|
className={inputClassName}
|
|
/>
|
|
);
|
|
|
|
case 'quantity':
|
|
return (
|
|
<QuantityInput
|
|
value={value}
|
|
onChange={(v) => onChangeNumber?.(v)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
error={!!error}
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
showButtons={showButtons}
|
|
suffix={suffix}
|
|
className={inputClassName}
|
|
/>
|
|
);
|
|
|
|
case 'text':
|
|
default:
|
|
return (
|
|
<Input
|
|
id={htmlFor}
|
|
type="text"
|
|
value={value as string}
|
|
onChange={(e) => onChange?.(e.target.value)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={className}>
|
|
<Label htmlFor={htmlFor}>
|
|
{label} {required && <span className="text-red-500">*</span>}
|
|
</Label>
|
|
|
|
<div className="mt-1">
|
|
{renderInput()}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="flex items-center gap-1 mt-1 text-sm text-red-500">
|
|
<AlertCircle className="h-3 w-3" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{helpText && !error && (
|
|
<p className="text-xs text-muted-foreground mt-1">{helpText}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
} |