This commit is contained in:
kjs 2025-12-19 16:20:59 +09:00
parent 9597494685
commit 034ef59ef9
4 changed files with 289 additions and 375 deletions

View File

@ -738,43 +738,28 @@ export class MenuCopyService {
]); ]);
logger.info(` ✅ 메뉴 권한 삭제 완료`); logger.info(` ✅ 메뉴 권한 삭제 완료`);
<<<<<<< HEAD
// 5-4. 채번 규칙 처리 (외래키 제약조건 해결)
// scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함)
=======
// 5-4. 채번 규칙 처리 (체크 제약조건 고려) // 5-4. 채번 규칙 처리 (체크 제약조건 고려)
// scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함) // scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함)
// check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수 // check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수
>>>>>>> 932eb288c6feae85cf7808513b4e1f0b7e709176
const menuScopedRulesResult = await client.query( const menuScopedRulesResult = await client.query(
`SELECT rule_id FROM numbering_rules `SELECT rule_id FROM numbering_rules
WHERE menu_objid = ANY($1) AND company_code = $2 AND scope_type = 'menu'`, WHERE menu_objid = ANY($1) AND company_code = $2 AND scope_type = 'menu'`,
[existingMenuIds, targetCompanyCode] [existingMenuIds, targetCompanyCode]
); );
if (menuScopedRulesResult.rows.length > 0) { if (menuScopedRulesResult.rows.length > 0) {
<<<<<<< HEAD
const menuScopedRuleIds = menuScopedRulesResult.rows.map(r => r.rule_id);
=======
const menuScopedRuleIds = menuScopedRulesResult.rows.map( const menuScopedRuleIds = menuScopedRulesResult.rows.map(
(r) => r.rule_id (r) => r.rule_id
); );
>>>>>>> 932eb288c6feae85cf7808513b4e1f0b7e709176
// 채번 규칙 파트 먼저 삭제 // 채번 규칙 파트 먼저 삭제
await client.query( await client.query(
`DELETE FROM numbering_rule_parts WHERE rule_id = ANY($1)`, `DELETE FROM numbering_rule_parts WHERE rule_id = ANY($1)`,
[menuScopedRuleIds] [menuScopedRuleIds]
); );
// 채번 규칙 삭제 // 채번 규칙 삭제
<<<<<<< HEAD
await client.query( await client.query(
`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, `DELETE FROM numbering_rules WHERE rule_id = ANY($1)`,
[menuScopedRuleIds] [menuScopedRuleIds]
); );
logger.info(` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}`);
=======
await client.query(`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, [
menuScopedRuleIds,
]);
logger.info( logger.info(
` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}` ` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}`
); );
@ -818,37 +803,7 @@ export class MenuCopyService {
await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [ await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [
existingMenuIds, existingMenuIds,
]); ]);
>>>>>>> 932eb288c6feae85cf7808513b4e1f0b7e709176
} }
// scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존)
const tableScopedRulesResult = await client.query(
`UPDATE numbering_rules
SET menu_objid = NULL
WHERE menu_objid = ANY($1) AND company_code = $2 AND (scope_type IS NULL OR scope_type != 'menu')
RETURNING rule_id`,
[existingMenuIds, targetCompanyCode]
);
if (tableScopedRulesResult.rows.length > 0) {
logger.info(` ✅ 테이블 스코프 채번 규칙 연결 해제: ${tableScopedRulesResult.rows.length}개 (데이터 보존)`);
}
// 5-5. 카테고리 컬럼 매핑 삭제 (NOT NULL 제약조건으로 인해 삭제)
const deletedCategoryMappings = await client.query(
`DELETE FROM category_column_mapping
WHERE menu_objid = ANY($1) AND company_code = $2
RETURNING mapping_id`,
[existingMenuIds, targetCompanyCode]
);
if (deletedCategoryMappings.rows.length > 0) {
logger.info(` ✅ 카테고리 매핑 삭제: ${deletedCategoryMappings.rows.length}`);
}
// 5-6. 메뉴 삭제 (배치)
await client.query(
`DELETE FROM menu_info WHERE objid = ANY($1)`,
[existingMenuIds]
);
logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}`); logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}`);
logger.info("✅ 기존 복사본 삭제 완료 - 덮어쓰기 준비됨"); logger.info("✅ 기존 복사본 삭제 완료 - 덮어쓰기 준비됨");
@ -2416,12 +2371,8 @@ export class MenuCopyService {
return { copiedCount, ruleIdMap }; return { copiedCount, ruleIdMap };
} }
<<<<<<< HEAD
// 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크)
=======
// 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회 // 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회
const ruleIds = allRulesResult.rows.map((r) => r.rule_id); const ruleIds = allRulesResult.rows.map((r) => r.rule_id);
>>>>>>> 932eb288c6feae85cf7808513b4e1f0b7e709176
const existingRulesResult = await client.query( const existingRulesResult = await client.query(
`SELECT rule_id FROM numbering_rules WHERE company_code = $1`, `SELECT rule_id FROM numbering_rules WHERE company_code = $1`,
[targetCompanyCode] [targetCompanyCode]
@ -2438,22 +2389,9 @@ export class MenuCopyService {
const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = []; const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = [];
for (const rule of allRulesResult.rows) { for (const rule of allRulesResult.rows) {
// 새 rule_id 생성
const originalSuffix = rule.rule_id.includes('_')
? rule.rule_id.replace(/^[^_]*_/, '')
: rule.rule_id;
const newRuleId = `${targetCompanyCode}_${originalSuffix}`;
// 원본 ID 또는 새로 생성될 ID가 이미 존재하는 경우 스킵
if (existingRuleIds.has(rule.rule_id)) { if (existingRuleIds.has(rule.rule_id)) {
// 기존 규칙은 동일한 ID로 매핑
ruleIdMap.set(rule.rule_id, rule.rule_id); ruleIdMap.set(rule.rule_id, rule.rule_id);
<<<<<<< HEAD
logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`);
} else if (existingRuleIds.has(newRuleId)) {
ruleIdMap.set(rule.rule_id, newRuleId);
logger.info(` ♻️ 채번규칙 이미 존재 (대상 ID): ${newRuleId}`);
} else {
=======
// 새 메뉴 ID로 연결 업데이트 필요 // 새 메뉴 ID로 연결 업데이트 필요
const newMenuObjid = menuIdMap.get(rule.menu_objid); const newMenuObjid = menuIdMap.get(rule.menu_objid);
@ -2470,7 +2408,6 @@ export class MenuCopyService {
: rule.rule_id; : rule.rule_id;
const newRuleId = `${targetCompanyCode}_${originalSuffix}`; const newRuleId = `${targetCompanyCode}_${originalSuffix}`;
>>>>>>> 932eb288c6feae85cf7808513b4e1f0b7e709176
ruleIdMap.set(rule.rule_id, newRuleId); ruleIdMap.set(rule.rule_id, newRuleId);
originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId });
rulesToCopy.push({ ...rule, newRuleId }); rulesToCopy.push({ ...rule, newRuleId });

View File

@ -319,14 +319,17 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
if (componentId?.startsWith("unified-")) { if (componentId?.startsWith("unified-")) {
const unifiedConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = { const unifiedConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
"unified-input": require("@/components/unified/config-panels/UnifiedInputConfigPanel").UnifiedInputConfigPanel, "unified-input": require("@/components/unified/config-panels/UnifiedInputConfigPanel").UnifiedInputConfigPanel,
"unified-select": require("@/components/unified/config-panels/UnifiedSelectConfigPanel").UnifiedSelectConfigPanel, "unified-select": require("@/components/unified/config-panels/UnifiedSelectConfigPanel")
.UnifiedSelectConfigPanel,
"unified-date": require("@/components/unified/config-panels/UnifiedDateConfigPanel").UnifiedDateConfigPanel, "unified-date": require("@/components/unified/config-panels/UnifiedDateConfigPanel").UnifiedDateConfigPanel,
"unified-list": require("@/components/unified/config-panels/UnifiedListConfigPanel").UnifiedListConfigPanel, "unified-list": require("@/components/unified/config-panels/UnifiedListConfigPanel").UnifiedListConfigPanel,
"unified-layout": require("@/components/unified/config-panels/UnifiedLayoutConfigPanel").UnifiedLayoutConfigPanel, "unified-layout": require("@/components/unified/config-panels/UnifiedLayoutConfigPanel")
.UnifiedLayoutConfigPanel,
"unified-group": require("@/components/unified/config-panels/UnifiedGroupConfigPanel").UnifiedGroupConfigPanel, "unified-group": require("@/components/unified/config-panels/UnifiedGroupConfigPanel").UnifiedGroupConfigPanel,
"unified-media": require("@/components/unified/config-panels/UnifiedMediaConfigPanel").UnifiedMediaConfigPanel, "unified-media": require("@/components/unified/config-panels/UnifiedMediaConfigPanel").UnifiedMediaConfigPanel,
"unified-biz": require("@/components/unified/config-panels/UnifiedBizConfigPanel").UnifiedBizConfigPanel, "unified-biz": require("@/components/unified/config-panels/UnifiedBizConfigPanel").UnifiedBizConfigPanel,
"unified-hierarchy": require("@/components/unified/config-panels/UnifiedHierarchyConfigPanel").UnifiedHierarchyConfigPanel, "unified-hierarchy": require("@/components/unified/config-panels/UnifiedHierarchyConfigPanel")
.UnifiedHierarchyConfigPanel,
}; };
const UnifiedConfigPanel = unifiedConfigPanels[componentId]; const UnifiedConfigPanel = unifiedConfigPanels[componentId];
@ -1038,9 +1041,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 🆕 3.5. Unified 컴포넌트 - 반드시 다른 체크보다 먼저 처리 // 🆕 3.5. Unified 컴포넌트 - 반드시 다른 체크보다 먼저 처리
const unifiedComponentType = const unifiedComponentType =
(selectedComponent as any).componentType || (selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
selectedComponent.componentConfig?.type ||
"";
if (unifiedComponentType.startsWith("unified-")) { if (unifiedComponentType.startsWith("unified-")) {
const configPanel = renderComponentConfigPanel(); const configPanel = renderComponentConfigPanel();
if (configPanel) { if (configPanel) {
@ -1538,7 +1539,15 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div> </div>
<div className="rounded-md border border-gray-200 p-2"> <div className="rounded-md border border-gray-200 p-2">
<ConditionalConfigPanel <ConditionalConfigPanel
config={(selectedComponent as any).conditional || { enabled: false, field: "", operator: "=", value: "", action: "show" }} config={
(selectedComponent as any).conditional || {
enabled: false,
field: "",
operator: "=",
value: "",
action: "show",
}
}
onChange={(newConfig: ConditionalConfig) => { onChange={(newConfig: ConditionalConfig) => {
handleUpdate("conditional", newConfig); handleUpdate("conditional", newConfig);
}} }}

View File

@ -2,7 +2,7 @@
/** /**
* UnifiedDate * UnifiedDate
* *
* / * /
* - date: 날짜 * - date: 날짜
* - time: 시간 * - time: 시간
@ -37,14 +37,14 @@ const DATE_FORMATS: Record<string, string> = {
// 날짜 문자열 → Date 객체 // 날짜 문자열 → Date 객체
function parseDate(value: string | undefined, formatStr: string): Date | undefined { function parseDate(value: string | undefined, formatStr: string): Date | undefined {
if (!value) return undefined; if (!value) return undefined;
const dateFnsFormat = DATE_FORMATS[formatStr] || formatStr; const dateFnsFormat = DATE_FORMATS[formatStr] || formatStr;
try { try {
// ISO 형식 먼저 시도 // ISO 형식 먼저 시도
const isoDate = new Date(value); const isoDate = new Date(value);
if (isValid(isoDate)) return isoDate; if (isValid(isoDate)) return isoDate;
// 포맷에 맞게 파싱 // 포맷에 맞게 파싱
const parsed = parse(value, dateFnsFormat, new Date()); const parsed = parse(value, dateFnsFormat, new Date());
return isValid(parsed) ? parsed : undefined; return isValid(parsed) ? parsed : undefined;
@ -63,151 +63,152 @@ function formatDate(date: Date | undefined, formatStr: string): string {
/** /**
* *
*/ */
const SingleDatePicker = forwardRef<HTMLButtonElement, { const SingleDatePicker = forwardRef<
value?: string; HTMLButtonElement,
onChange?: (value: string) => void; {
dateFormat: string; value?: string;
showToday?: boolean; onChange?: (value: string) => void;
minDate?: string; dateFormat: string;
maxDate?: string; showToday?: boolean;
disabled?: boolean; minDate?: string;
readonly?: boolean; maxDate?: string;
className?: string; disabled?: boolean;
}>(({ readonly?: boolean;
value, className?: string;
onChange, }
dateFormat = "YYYY-MM-DD", >(
showToday = true, (
minDate, { value, onChange, dateFormat = "YYYY-MM-DD", showToday = true, minDate, maxDate, disabled, readonly, className },
maxDate, ref,
disabled, ) => {
readonly, const [open, setOpen] = useState(false);
className
}, ref) => {
const [open, setOpen] = useState(false);
const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]);
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
const handleSelect = useCallback((selectedDate: Date | undefined) => { const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]);
if (selectedDate) { const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
onChange?.(formatDate(selectedDate, dateFormat)); const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
const handleSelect = useCallback(
(selectedDate: Date | undefined) => {
if (selectedDate) {
onChange?.(formatDate(selectedDate, dateFormat));
setOpen(false);
}
},
[dateFormat, onChange],
);
const handleToday = useCallback(() => {
onChange?.(formatDate(new Date(), dateFormat));
setOpen(false); setOpen(false);
} }, [dateFormat, onChange]);
}, [dateFormat, onChange]);
const handleToday = useCallback(() => { const handleClear = useCallback(() => {
onChange?.(formatDate(new Date(), dateFormat)); onChange?.("");
setOpen(false); setOpen(false);
}, [dateFormat, onChange]); }, [onChange]);
const handleClear = useCallback(() => { return (
onChange?.(""); <Popover open={open} onOpenChange={setOpen}>
setOpen(false); <PopoverTrigger asChild>
}, [onChange]); <Button
ref={ref}
return ( variant="outline"
<Popover open={open} onOpenChange={setOpen}> disabled={disabled || readonly}
<PopoverTrigger asChild> className={cn(
<Button "h-10 w-full justify-start text-left font-normal",
ref={ref} !value && "text-muted-foreground",
variant="outline" className,
disabled={disabled || readonly} )}
className={cn( >
"h-10 w-full justify-start text-left font-normal", <CalendarIcon className="mr-2 h-4 w-4" />
!value && "text-muted-foreground", {value || "날짜 선택"}
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value || "날짜 선택"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleSelect}
initialFocus
locale={ko}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
return false;
}}
/>
<div className="flex gap-2 p-3 pt-0">
{showToday && (
<Button variant="outline" size="sm" onClick={handleToday}>
</Button>
)}
<Button variant="ghost" size="sm" onClick={handleClear}>
</Button> </Button>
</div> </PopoverTrigger>
</PopoverContent> <PopoverContent className="w-auto p-0" align="start">
</Popover> <Calendar
); mode="single"
}); selected={date}
onSelect={handleSelect}
initialFocus
locale={ko}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
return false;
}}
/>
<div className="flex gap-2 p-3 pt-0">
{showToday && (
<Button variant="outline" size="sm" onClick={handleToday}>
</Button>
)}
<Button variant="ghost" size="sm" onClick={handleClear}>
</Button>
</div>
</PopoverContent>
</Popover>
);
},
);
SingleDatePicker.displayName = "SingleDatePicker"; SingleDatePicker.displayName = "SingleDatePicker";
/** /**
* *
*/ */
const RangeDatePicker = forwardRef<HTMLDivElement, { const RangeDatePicker = forwardRef<
value?: [string, string]; HTMLDivElement,
onChange?: (value: [string, string]) => void; {
dateFormat: string; value?: [string, string];
minDate?: string; onChange?: (value: [string, string]) => void;
maxDate?: string; dateFormat: string;
disabled?: boolean; minDate?: string;
readonly?: boolean; maxDate?: string;
className?: string; disabled?: boolean;
}>(({ readonly?: boolean;
value = ["", ""], className?: string;
onChange, }
dateFormat = "YYYY-MM-DD", >(({ value = ["", ""], onChange, dateFormat = "YYYY-MM-DD", minDate, maxDate, disabled, readonly, className }, ref) => {
minDate,
maxDate,
disabled,
readonly,
className
}, ref) => {
const [openStart, setOpenStart] = useState(false); const [openStart, setOpenStart] = useState(false);
const [openEnd, setOpenEnd] = useState(false); const [openEnd, setOpenEnd] = useState(false);
const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]); const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]);
const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]); const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]);
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
const handleStartSelect = useCallback((date: Date | undefined) => { const handleStartSelect = useCallback(
if (date) { (date: Date | undefined) => {
const newStart = formatDate(date, dateFormat); if (date) {
// 시작일이 종료일보다 크면 종료일도 같이 변경 const newStart = formatDate(date, dateFormat);
if (endDate && date > endDate) { // 시작일이 종료일보다 크면 종료일도 같이 변경
onChange?.([newStart, newStart]); if (endDate && date > endDate) {
} else { onChange?.([newStart, newStart]);
onChange?.([newStart, value[1]]); } else {
onChange?.([newStart, value[1]]);
}
setOpenStart(false);
} }
setOpenStart(false); },
} [value, dateFormat, endDate, onChange],
}, [value, dateFormat, endDate, onChange]); );
const handleEndSelect = useCallback((date: Date | undefined) => { const handleEndSelect = useCallback(
if (date) { (date: Date | undefined) => {
const newEnd = formatDate(date, dateFormat); if (date) {
// 종료일이 시작일보다 작으면 시작일도 같이 변경 const newEnd = formatDate(date, dateFormat);
if (startDate && date < startDate) { // 종료일이 시작일보다 작으면 시작일도 같이 변경
onChange?.([newEnd, newEnd]); if (startDate && date < startDate) {
} else { onChange?.([newEnd, newEnd]);
onChange?.([value[0], newEnd]); } else {
onChange?.([value[0], newEnd]);
}
setOpenEnd(false);
} }
setOpenEnd(false); },
} [value, dateFormat, startDate, onChange],
}, [value, dateFormat, startDate, onChange]); );
return ( return (
<div ref={ref} className={cn("flex items-center gap-2", className)}> <div ref={ref} className={cn("flex items-center gap-2", className)}>
@ -217,10 +218,7 @@ const RangeDatePicker = forwardRef<HTMLDivElement, {
<Button <Button
variant="outline" variant="outline"
disabled={disabled || readonly} disabled={disabled || readonly}
className={cn( className={cn("h-10 flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")}
"h-10 flex-1 justify-start text-left font-normal",
!value[0] && "text-muted-foreground"
)}
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{value[0] || "시작일"} {value[0] || "시작일"}
@ -250,10 +248,7 @@ const RangeDatePicker = forwardRef<HTMLDivElement, {
<Button <Button
variant="outline" variant="outline"
disabled={disabled || readonly} disabled={disabled || readonly}
className={cn( className={cn("h-10 flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")}
"h-10 flex-1 justify-start text-left font-normal",
!value[1] && "text-muted-foreground"
)}
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{value[1] || "종료일"} {value[1] || "종료일"}
@ -284,16 +279,19 @@ RangeDatePicker.displayName = "RangeDatePicker";
/** /**
* *
*/ */
const TimePicker = forwardRef<HTMLInputElement, { const TimePicker = forwardRef<
value?: string; HTMLInputElement,
onChange?: (value: string) => void; {
disabled?: boolean; value?: string;
readonly?: boolean; onChange?: (value: string) => void;
className?: string; disabled?: boolean;
}>(({ value, onChange, disabled, readonly, className }, ref) => { readonly?: boolean;
className?: string;
}
>(({ value, onChange, disabled, readonly, className }, ref) => {
return ( return (
<div className={cn("relative", className)}> <div className={cn("relative", className)}>
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Clock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
ref={ref} ref={ref}
type="time" type="time"
@ -311,25 +309,19 @@ TimePicker.displayName = "TimePicker";
/** /**
* + * +
*/ */
const DateTimePicker = forwardRef<HTMLDivElement, { const DateTimePicker = forwardRef<
value?: string; HTMLDivElement,
onChange?: (value: string) => void; {
dateFormat: string; value?: string;
minDate?: string; onChange?: (value: string) => void;
maxDate?: string; dateFormat: string;
disabled?: boolean; minDate?: string;
readonly?: boolean; maxDate?: string;
className?: string; disabled?: boolean;
}>(({ readonly?: boolean;
value, className?: string;
onChange, }
dateFormat = "YYYY-MM-DD HH:mm", >(({ value, onChange, dateFormat = "YYYY-MM-DD HH:mm", minDate, maxDate, disabled, readonly, className }, ref) => {
minDate,
maxDate,
disabled,
readonly,
className
}, ref) => {
// 날짜와 시간 분리 // 날짜와 시간 분리
const [datePart, timePart] = useMemo(() => { const [datePart, timePart] = useMemo(() => {
if (!value) return ["", ""]; if (!value) return ["", ""];
@ -337,15 +329,21 @@ const DateTimePicker = forwardRef<HTMLDivElement, {
return [parts[0] || "", parts[1] || ""]; return [parts[0] || "", parts[1] || ""];
}, [value]); }, [value]);
const handleDateChange = useCallback((newDate: string) => { const handleDateChange = useCallback(
const newValue = `${newDate} ${timePart || "00:00"}`; (newDate: string) => {
onChange?.(newValue.trim()); const newValue = `${newDate} ${timePart || "00:00"}`;
}, [timePart, onChange]); onChange?.(newValue.trim());
},
[timePart, onChange],
);
const handleTimeChange = useCallback((newTime: string) => { const handleTimeChange = useCallback(
const newValue = `${datePart || format(new Date(), "yyyy-MM-dd")} ${newTime}`; (newTime: string) => {
onChange?.(newValue.trim()); const newValue = `${datePart || format(new Date(), "yyyy-MM-dd")} ${newTime}`;
}, [datePart, onChange]); onChange?.(newValue.trim());
},
[datePart, onChange],
);
return ( return (
<div ref={ref} className={cn("flex gap-2", className)}> <div ref={ref} className={cn("flex gap-2", className)}>
@ -361,12 +359,7 @@ const DateTimePicker = forwardRef<HTMLDivElement, {
/> />
</div> </div>
<div className="w-32"> <div className="w-32">
<TimePicker <TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
value={timePart}
onChange={handleTimeChange}
disabled={disabled}
readonly={readonly}
/>
</div> </div>
</div> </div>
); );
@ -376,36 +369,64 @@ DateTimePicker.displayName = "DateTimePicker";
/** /**
* UnifiedDate * UnifiedDate
*/ */
export const UnifiedDate = forwardRef<HTMLDivElement, UnifiedDateProps>( export const UnifiedDate = forwardRef<HTMLDivElement, UnifiedDateProps>((props, ref) => {
(props, ref) => { const { id, label, required, readonly, disabled, style, size, config: configProp, value, onChange } = props;
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
} = props;
// config가 없으면 기본값 사용 // config가 없으면 기본값 사용
const config = configProp || { type: "date" as const }; const config = configProp || { type: "date" as const };
const dateFormat = config.format || "YYYY-MM-DD"; const dateFormat = config.format || "YYYY-MM-DD";
// 타입별 컴포넌트 렌더링 // 타입별 컴포넌트 렌더링
const renderDatePicker = () => { const renderDatePicker = () => {
const isDisabled = disabled || readonly; const isDisabled = disabled || readonly;
// 범위 선택 // 범위 선택
if (config.range) { if (config.range) {
return (
<RangeDatePicker
value={Array.isArray(value) ? (value as [string, string]) : ["", ""]}
onChange={onChange as (value: [string, string]) => void}
dateFormat={dateFormat}
minDate={config.minDate}
maxDate={config.maxDate}
disabled={isDisabled}
readonly={readonly}
/>
);
}
// 타입별 렌더링
switch (config.type) {
case "date":
return ( return (
<RangeDatePicker <SingleDatePicker
value={Array.isArray(value) ? value as [string, string] : ["", ""]} value={typeof value === "string" ? value : ""}
onChange={onChange as (value: [string, string]) => void} onChange={(v) => onChange?.(v)}
dateFormat={dateFormat}
showToday={config.showToday}
minDate={config.minDate}
maxDate={config.maxDate}
disabled={isDisabled}
readonly={readonly}
/>
);
case "time":
return (
<TimePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
disabled={isDisabled}
readonly={readonly}
/>
);
case "datetime":
return (
<DateTimePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
dateFormat={dateFormat} dateFormat={dateFormat}
minDate={config.minDate} minDate={config.minDate}
maxDate={config.maxDate} maxDate={config.maxDate}
@ -413,99 +434,55 @@ export const UnifiedDate = forwardRef<HTMLDivElement, UnifiedDateProps>(
readonly={readonly} readonly={readonly}
/> />
); );
}
// 타입별 렌더링 default:
switch (config.type) { return (
case "date": <SingleDatePicker
return ( value={typeof value === "string" ? value : ""}
<SingleDatePicker onChange={(v) => onChange?.(v)}
value={typeof value === "string" ? value : ""} dateFormat={dateFormat}
onChange={(v) => onChange?.(v)} showToday={config.showToday}
dateFormat={dateFormat} disabled={isDisabled}
showToday={config.showToday} readonly={readonly}
minDate={config.minDate} />
maxDate={config.maxDate} );
disabled={isDisabled} }
readonly={readonly} };
/>
);
case "time": const showLabel = label && style?.labelDisplay !== false;
return ( const componentWidth = size?.width || style?.width;
<TimePicker const componentHeight = size?.height || style?.height;
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
disabled={isDisabled}
readonly={readonly}
/>
);
case "datetime": return (
return ( <div
<DateTimePicker ref={ref}
value={typeof value === "string" ? value : ""} id={id}
onChange={(v) => onChange?.(v)} className="flex flex-col"
dateFormat={dateFormat} style={{
minDate={config.minDate} width: componentWidth,
maxDate={config.maxDate} height: componentHeight,
disabled={isDisabled} }}
readonly={readonly} >
/> {showLabel && (
); <Label
htmlFor={id}
default: style={{
return ( fontSize: style?.labelFontSize,
<SingleDatePicker color: style?.labelColor,
value={typeof value === "string" ? value : ""} fontWeight: style?.labelFontWeight,
onChange={(v) => onChange?.(v)} marginBottom: style?.labelMarginBottom,
dateFormat={dateFormat} }}
showToday={config.showToday} className="flex-shrink-0 text-sm font-medium"
disabled={isDisabled} >
readonly={readonly} {label}
/> {required && <span className="ml-0.5 text-orange-500">*</span>}
); </Label>
} )}
}; <div className="min-h-0 flex-1">{renderDatePicker()}</div>
</div>
const showLabel = label && style?.labelDisplay !== false; );
const componentWidth = size?.width || style?.width; });
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div className="flex-1 min-h-0">
{renderDatePicker()}
</div>
</div>
);
}
);
UnifiedDate.displayName = "UnifiedDate"; UnifiedDate.displayName = "UnifiedDate";
export default UnifiedDate; export default UnifiedDate;

View File

@ -16,10 +16,7 @@ interface UnifiedInputConfigPanelProps {
onChange: (config: Record<string, any>) => void; onChange: (config: Record<string, any>) => void;
} }
export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = ({ export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = ({ config, onChange }) => {
config,
onChange,
}) => {
// 설정 업데이트 핸들러 // 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => { const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value }); onChange({ ...config, [field]: value });
@ -54,10 +51,7 @@ export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = (
{(config.inputType === "text" || !config.inputType) && ( {(config.inputType === "text" || !config.inputType) && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
<Select <Select value={config.format || "none"} onValueChange={(value) => updateConfig("format", value)}>
value={config.format || "none"}
onValueChange={(value) => updateConfig("format", value)}
>
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="형식 선택" /> <SelectValue placeholder="형식 선택" />
</SelectTrigger> </SelectTrigger>
@ -147,9 +141,7 @@ export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = (
placeholder="예: ###-####-####" placeholder="예: ###-####-####"
className="h-8 text-xs" className="h-8 text-xs"
/> />
<p className="text-[10px] text-muted-foreground"> <p className="text-muted-foreground text-[10px]"># = , A = , * = </p>
# = , A = , * =
</p>
</div> </div>
</div> </div>
); );
@ -158,4 +150,3 @@ export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = (
UnifiedInputConfigPanel.displayName = "UnifiedInputConfigPanel"; UnifiedInputConfigPanel.displayName = "UnifiedInputConfigPanel";
export default UnifiedInputConfigPanel; export default UnifiedInputConfigPanel;