129 lines
3.8 KiB
TypeScript
129 lines
3.8 KiB
TypeScript
|
|
/**
|
||
|
|
* 값+단위 조합 입력 필드
|
||
|
|
* Input(숫자) + Select(단위) 가로 배치
|
||
|
|
*
|
||
|
|
* properties: { units, defaultUnit, precision }
|
||
|
|
* 저장값: { value: number, unit: string }
|
||
|
|
*/
|
||
|
|
|
||
|
|
'use client';
|
||
|
|
|
||
|
|
import { useCallback } from 'react';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { Label } from '@/components/ui/label';
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from '@/components/ui/select';
|
||
|
|
import type { DynamicFieldRendererProps, UnitValueConfig } from '../types';
|
||
|
|
|
||
|
|
interface UnitValueData {
|
||
|
|
value: number | null;
|
||
|
|
unit: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseUnitValue(raw: unknown, defaultUnit: string): UnitValueData {
|
||
|
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||
|
|
const obj = raw as Record<string, unknown>;
|
||
|
|
return {
|
||
|
|
value: obj.value !== null && obj.value !== undefined ? Number(obj.value) : null,
|
||
|
|
unit: typeof obj.unit === 'string' ? obj.unit : defaultUnit,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
// 숫자만 들어온 경우
|
||
|
|
if (typeof raw === 'number') {
|
||
|
|
return { value: raw, unit: defaultUnit };
|
||
|
|
}
|
||
|
|
return { value: null, unit: defaultUnit };
|
||
|
|
}
|
||
|
|
|
||
|
|
export function UnitValueField({
|
||
|
|
field,
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
error,
|
||
|
|
disabled,
|
||
|
|
}: DynamicFieldRendererProps) {
|
||
|
|
const fieldKey = field.field_key || `field_${field.id}`;
|
||
|
|
const config = (field.properties || {}) as UnitValueConfig;
|
||
|
|
const units = config.units || [];
|
||
|
|
const defaultUnit = config.defaultUnit || (units.length > 0 ? units[0].value : '');
|
||
|
|
const precision = config.precision;
|
||
|
|
|
||
|
|
const data = parseUnitValue(value, defaultUnit);
|
||
|
|
|
||
|
|
const handleValueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||
|
|
const raw = e.target.value;
|
||
|
|
if (raw === '' || raw === '-') {
|
||
|
|
onChange({ value: null, unit: data.unit });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const num = parseFloat(raw);
|
||
|
|
if (!isNaN(num)) {
|
||
|
|
const final = precision !== undefined
|
||
|
|
? Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision)
|
||
|
|
: num;
|
||
|
|
onChange({ value: final, unit: data.unit });
|
||
|
|
}
|
||
|
|
}, [data.unit, precision, onChange]);
|
||
|
|
|
||
|
|
const handleUnitChange = useCallback((newUnit: string) => {
|
||
|
|
onChange({ value: data.value, unit: newUnit });
|
||
|
|
}, [data.value, onChange]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<Label htmlFor={fieldKey}>
|
||
|
|
{field.field_name}
|
||
|
|
{field.is_required && <span className="text-red-500"> *</span>}
|
||
|
|
</Label>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<Input
|
||
|
|
id={fieldKey}
|
||
|
|
type="number"
|
||
|
|
placeholder={field.placeholder || '값 입력'}
|
||
|
|
value={data.value !== null ? String(data.value) : ''}
|
||
|
|
onChange={handleValueChange}
|
||
|
|
disabled={disabled}
|
||
|
|
step={precision !== undefined ? Math.pow(10, -precision) : 'any'}
|
||
|
|
className={`flex-1 ${error ? 'border-red-500' : ''}`}
|
||
|
|
/>
|
||
|
|
{units.length > 0 ? (
|
||
|
|
<Select
|
||
|
|
key={`${fieldKey}-unit-${data.unit}`}
|
||
|
|
value={data.unit}
|
||
|
|
onValueChange={handleUnitChange}
|
||
|
|
disabled={disabled}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="w-24 shrink-0">
|
||
|
|
<SelectValue placeholder="단위" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{units.map((u) => (
|
||
|
|
<SelectItem key={u.value} value={u.value}>
|
||
|
|
{u.label}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
) : (
|
||
|
|
<span className="flex items-center text-sm text-muted-foreground px-2">
|
||
|
|
{data.unit || '-'}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{error && (
|
||
|
|
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||
|
|
)}
|
||
|
|
{!error && field.description && (
|
||
|
|
<p className="text-xs text-muted-foreground mt-1">
|
||
|
|
* {field.description}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|