feat(WEB): CEO 대시보드 캘린더 강화 및 validation 모듈 분리
- CalendarSection 일정 CRUD 기능 확장 (상세 모달 연동) - ScheduleDetailModal 개선 - CEO 대시보드 섹션별 API 키 통일 - validation.ts → validation/ 모듈 분리 (item-schemas, utils) - formatters.ts 확장 - date.ts 유틸 추가 - SignupPage/EmployeeForm/AddCompanyDialog 등 소규모 개선 - PaymentHistory/PopupManagement utils 정리 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { extractDigits } from '@/lib/formatters';
|
||||
|
||||
export function SignupPage() {
|
||||
const router = useRouter();
|
||||
@@ -64,7 +65,7 @@ export function SignupPage() {
|
||||
// 사업자등록번호 자동 포맷팅 (000-00-00000)
|
||||
const formatBusinessNumber = (value: string) => {
|
||||
// 숫자만 추출
|
||||
const numbers = value.replace(/[^\d]/g, '');
|
||||
const numbers = extractDigits(value);
|
||||
|
||||
// 최대 10자리까지만
|
||||
const limited = numbers.slice(0, 10);
|
||||
@@ -87,7 +88,7 @@ export function SignupPage() {
|
||||
// 핸드폰 번호 자동 포맷팅 (010-1111-1111 or 010-111-1111)
|
||||
const formatPhoneNumber = (value: string) => {
|
||||
// 숫자만 추출
|
||||
const numbers = value.replace(/[^\d]/g, '');
|
||||
const numbers = extractDigits(value);
|
||||
|
||||
// 최대 11자리까지만
|
||||
const limited = numbers.slice(0, 11);
|
||||
|
||||
@@ -889,7 +889,7 @@ export function DashboardSettingsDialog({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-3 p-4 border-t border-gray-200 sm:justify-center">
|
||||
<DialogFooter className="flex flex-row gap-3 p-4 border-t border-gray-200 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
|
||||
@@ -126,35 +126,30 @@ export function ScheduleDetailModal({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="w-[95vw] max-w-[480px] sm:max-w-[480px] p-6">
|
||||
<DialogContent className="w-[95vw] max-w-[480px] sm:max-w-[480px] p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader className="pb-2">
|
||||
<DialogTitle className="text-lg font-bold">일정 상세</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 제목 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
|
||||
제목
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">제목</label>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => handleFieldChange('title', e.target.value)}
|
||||
placeholder="제목"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 대상 (부서) */}
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
|
||||
대상
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">대상</label>
|
||||
<Select
|
||||
value={formData.department}
|
||||
onValueChange={(value) => handleFieldChange('department', value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="부서명" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -168,33 +163,31 @@ export function ScheduleDetailModal({
|
||||
</div>
|
||||
|
||||
{/* 기간 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
|
||||
기간
|
||||
</label>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">기간</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<DatePicker
|
||||
value={formData.startDate}
|
||||
onChange={(value) => handleFieldChange('startDate', value)}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-gray-400 px-1">~</span>
|
||||
<DatePicker
|
||||
value={formData.endDate}
|
||||
onChange={(value) => handleFieldChange('endDate', value)}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400 text-xs">~</span>
|
||||
<DatePicker
|
||||
value={formData.endDate}
|
||||
onChange={(value) => handleFieldChange('endDate', value)}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시간 */}
|
||||
<div className="flex items-start gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0 pt-2">
|
||||
시간
|
||||
</label>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">시간</label>
|
||||
<div className="space-y-3">
|
||||
{/* 종일 체크박스 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
@@ -215,15 +208,15 @@ export function ScheduleDetailModal({
|
||||
value={formData.startTime}
|
||||
onChange={(value) => handleFieldChange('startTime', value)}
|
||||
placeholder="시작 시간"
|
||||
className="flex-1"
|
||||
className="flex-1 min-w-0"
|
||||
minuteStep={5}
|
||||
/>
|
||||
<span className="text-gray-400 px-1">~</span>
|
||||
<span className="text-gray-400 shrink-0">~</span>
|
||||
<TimePicker
|
||||
value={formData.endTime}
|
||||
onChange={(value) => handleFieldChange('endTime', value)}
|
||||
placeholder="종료 시간"
|
||||
className="flex-1"
|
||||
className="flex-1 min-w-0"
|
||||
minuteStep={5}
|
||||
/>
|
||||
</div>
|
||||
@@ -232,11 +225,9 @@ export function ScheduleDetailModal({
|
||||
</div>
|
||||
|
||||
{/* 색상 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
|
||||
색상
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">색상</label>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{COLOR_OPTIONS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
@@ -254,20 +245,18 @@ export function ScheduleDetailModal({
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex items-start gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0 pt-2">
|
||||
내용
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">내용</label>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => handleFieldChange('content', e.target.value)}
|
||||
placeholder="내용"
|
||||
className="flex-1 min-h-[120px] resize-none"
|
||||
className="min-h-[100px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2">
|
||||
<DialogFooter className="flex flex-row gap-2 pt-2">
|
||||
{isEditMode && onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, ExternalLink } from 'lucide-react';
|
||||
import { Plus, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar';
|
||||
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar/types';
|
||||
import { getCalendarEventsForYear, type CalendarEvent } from '@/constants/calendarEvents';
|
||||
@@ -243,50 +243,238 @@ export function CalendarSection({
|
||||
setCurrentDate(date);
|
||||
};
|
||||
|
||||
// 모바일 리스트뷰: 현재 월의 모든 날짜와 이벤트
|
||||
const monthDaysWithEvents = useMemo(() => {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const days: Array<{
|
||||
date: Date;
|
||||
dateStr: string;
|
||||
label: string;
|
||||
isToday: boolean;
|
||||
isWeekend: boolean;
|
||||
events: ScheduleEvent[];
|
||||
}> = [];
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const date = new Date(year, month, d);
|
||||
const mm = String(month + 1).padStart(2, '0');
|
||||
const dd = String(d).padStart(2, '0');
|
||||
const dateStr = `${year}-${mm}-${dd}`;
|
||||
const dayOfWeek = date.getDay();
|
||||
|
||||
const dayEvents = calendarEvents.filter(
|
||||
(ev) => ev.startDate <= dateStr && ev.endDate >= dateStr
|
||||
);
|
||||
|
||||
days.push({
|
||||
date,
|
||||
dateStr,
|
||||
label: `${d}일 ${dayNames[dayOfWeek]}요일`,
|
||||
isToday: date.getTime() === today.getTime(),
|
||||
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
||||
events: dayEvents,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
}, [currentDate, calendarEvents]);
|
||||
|
||||
const handleMobilePrevMonth = () => {
|
||||
const prev = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1);
|
||||
setCurrentDate(prev);
|
||||
};
|
||||
|
||||
const handleMobileNextMonth = () => {
|
||||
const next = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
|
||||
setCurrentDate(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
{/* 섹션 헤더: 타이틀 + 필터들 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">캘린더</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 부서 필터 */}
|
||||
<Select
|
||||
value={deptFilter}
|
||||
onValueChange={(value) => setDeptFilter(value as CalendarDeptFilterType)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[80px] w-auto h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPT_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 모바일: 스티키 헤더 (타이틀+필터+월네비) */}
|
||||
<div className="lg:hidden sticky top-12 z-10 bg-white -mx-6 px-6 pt-2 pb-3 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<h3 className="text-lg font-semibold shrink-0">캘린더</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 부서 필터 */}
|
||||
<Select
|
||||
value={deptFilter}
|
||||
onValueChange={(value) => setDeptFilter(value as CalendarDeptFilterType)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[80px] w-auto h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPT_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 업무 필터 */}
|
||||
<Select
|
||||
value={taskFilter}
|
||||
onValueChange={(value) => setTaskFilter(value as CalendarTaskFilterType)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[80px] w-auto h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TASK_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 업무 필터 */}
|
||||
<Select
|
||||
value={taskFilter}
|
||||
onValueChange={(value) => setTaskFilter(value as CalendarTaskFilterType)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[80px] w-auto h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TASK_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 월 네비게이션 */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button variant="outline" size="sm" className="h-8 px-2" onClick={handleMobilePrevMonth}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-semibold whitespace-nowrap">
|
||||
{currentDate.getFullYear()}년 {currentDate.getMonth() + 1}월
|
||||
</span>
|
||||
<Button variant="outline" size="sm" className="h-8 px-2" onClick={handleMobileNextMonth}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 데스크탑: 일반 헤더 */}
|
||||
<div className="hidden lg:flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<h3 className="text-lg font-semibold shrink-0">캘린더</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={deptFilter}
|
||||
onValueChange={(value) => setDeptFilter(value as CalendarDeptFilterType)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[80px] w-auto h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPT_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={taskFilter}
|
||||
onValueChange={(value) => setTaskFilter(value as CalendarTaskFilterType)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[80px] w-auto h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TASK_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일: 리스트뷰 */}
|
||||
<div className="lg:hidden pt-3">
|
||||
|
||||
{/* 일별 리스트 */}
|
||||
<div className="space-y-1">
|
||||
{monthDaysWithEvents.map((day) => {
|
||||
const hasEvents = day.events.length > 0;
|
||||
const isSelected = selectedDate && day.date.getTime() === selectedDate.getTime();
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
className={`rounded-lg px-3 py-2.5 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-blue-50 ring-1 ring-blue-200' :
|
||||
day.isToday ? 'bg-amber-50' : ''
|
||||
} ${!hasEvents && !day.isToday && !isSelected ? 'opacity-50' : ''}`}
|
||||
onClick={() => handleDateClick(day.date)}
|
||||
>
|
||||
{/* 날짜 + 일정등록 버튼 */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className={`text-sm font-medium ${
|
||||
day.isWeekend ? 'text-red-500' : 'text-gray-900'
|
||||
} ${day.isToday ? 'font-bold' : ''}`}>
|
||||
{day.label}
|
||||
{day.isToday && <span className="ml-1 text-amber-600 text-xs font-semibold">(오늘)</span>}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs gap-0.5 px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onScheduleEdit?.({
|
||||
id: '',
|
||||
title: '',
|
||||
startDate: day.dateStr,
|
||||
endDate: day.dateStr,
|
||||
type: 'schedule',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
일정등록
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이벤트 목록 (날짜 아래) */}
|
||||
{hasEvents ? (
|
||||
<div className="space-y-1 pl-1">
|
||||
{day.events.slice(0, 3).map((ev) => {
|
||||
const evData = ev.data as Record<string, unknown>;
|
||||
const evType = evData?._type as string;
|
||||
const colorMap: Record<string, string> = {
|
||||
holiday: 'bg-red-500',
|
||||
tax: 'bg-orange-500',
|
||||
schedule: 'bg-blue-500',
|
||||
order: 'bg-green-500',
|
||||
construction: 'bg-purple-500',
|
||||
issue: 'bg-red-400',
|
||||
};
|
||||
const dotColor = colorMap[evType] || 'bg-gray-400';
|
||||
const title = evData?.name as string || evData?.title as string || ev.title;
|
||||
const cleanTitle = title?.replace(/^[🔴🟠]\s*/, '') || '';
|
||||
return (
|
||||
<div key={ev.id} className="flex items-center gap-1.5 text-xs text-gray-700">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
|
||||
<span className="truncate">{cleanTitle}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{day.events.length > 3 && (
|
||||
<div className="text-xs text-gray-400 pl-3.5">+{day.events.length - 3}건</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데스크탑: 기존 캘린더 + 상세 */}
|
||||
<div className="hidden lg:grid lg:grid-cols-2 gap-6">
|
||||
{/* 캘린더 영역 */}
|
||||
<div>
|
||||
<ScheduleCalendar
|
||||
@@ -297,25 +485,22 @@ export function CalendarSection({
|
||||
onEventClick={handleEventClick}
|
||||
onMonthChange={handleMonthChange}
|
||||
maxEventsPerDay={4}
|
||||
weekStartsOn={1} // 월요일 시작 (기획서)
|
||||
weekStartsOn={1}
|
||||
className="[&_.weekend]:bg-yellow-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택된 날짜 일정 + 이슈 목록 */}
|
||||
<div className="border rounded-lg p-4">
|
||||
{/* 헤더: 날짜 + 일정등록 버튼 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-lg font-semibold">
|
||||
{selectedDate ? formatSelectedDate(selectedDate) : '날짜를 선택하세요'}
|
||||
</h4>
|
||||
{/* 일정등록 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={() => {
|
||||
// 선택된 날짜 기준으로 새 일정 등록
|
||||
const year = selectedDate?.getFullYear() || new Date().getFullYear();
|
||||
const month = String((selectedDate?.getMonth() || new Date().getMonth()) + 1).padStart(2, '0');
|
||||
const day = String(selectedDate?.getDate() || new Date().getDate()).padStart(2, '0');
|
||||
@@ -334,7 +519,6 @@ export function CalendarSection({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 총 N건 */}
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
총 {totalItemCount}건
|
||||
</div>
|
||||
@@ -345,7 +529,6 @@ export function CalendarSection({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[calc(100vh-400px)] overflow-y-auto pr-1">
|
||||
{/* 공휴일/세금일정 목록 */}
|
||||
{selectedDateItems.staticEvents.map((event) => {
|
||||
const eventData = event.data as CalendarEvent & { _type: string };
|
||||
const isHoliday = eventData.type === 'holiday';
|
||||
@@ -369,36 +552,25 @@ export function CalendarSection({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 일정 목록 */}
|
||||
{selectedDateItems.schedules.map((schedule) => (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className="p-3 border rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
onClick={() => onScheduleClick?.(schedule)}
|
||||
>
|
||||
{/* 제목 */}
|
||||
<div className="font-medium text-base mb-1">
|
||||
{schedule.title}
|
||||
</div>
|
||||
{/* 부서명 | 날짜 | 시간 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatScheduleDetail(schedule)}
|
||||
</div>
|
||||
<div className="font-medium text-base mb-1">{schedule.title}</div>
|
||||
<div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 이슈 목록 */}
|
||||
{selectedDateItems.issues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="p-3 border border-red-200 rounded-lg hover:bg-red-50 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
if (issue.path) {
|
||||
router.push(`/ko${issue.path}`);
|
||||
}
|
||||
if (issue.path) router.push(`/ko${issue.path}`);
|
||||
}}
|
||||
>
|
||||
{/* 뱃지 + 제목 */}
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -406,11 +578,8 @@ export function CalendarSection({
|
||||
>
|
||||
{issue.badge}
|
||||
</Badge>
|
||||
<span className="font-medium text-sm flex-1">
|
||||
{issue.content}
|
||||
</span>
|
||||
<span className="font-medium text-sm flex-1">{issue.content}</span>
|
||||
</div>
|
||||
{/* 시간 + 상세보기 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{issue.time}</span>
|
||||
{issue.path && (
|
||||
|
||||
@@ -120,7 +120,7 @@ export function DailyAttendanceSection({ data }: DailyAttendanceSectionProps) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-sm min-w-[400px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<th className="px-4 py-2 text-center text-gray-600 font-medium w-12">No</th>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function DebtCollectionSection({ data }: DebtCollectionSectionProps) {
|
||||
colorTheme="red"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card, idx) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
|
||||
|
||||
{/* 카드 내용 */}
|
||||
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{data.cards.map((card, idx) => {
|
||||
const style = CARD_STYLES[idx] || CARD_STYLES[0];
|
||||
const CardIcon = style.Icon;
|
||||
@@ -240,7 +240,7 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
|
||||
</div>
|
||||
|
||||
{/* 카드 그리드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{filteredItems.map((item) => {
|
||||
const isHighlighted = item.isHighlighted;
|
||||
const style = ITEM_STYLES[item.label] || DEFAULT_STYLE;
|
||||
@@ -325,7 +325,7 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon
|
||||
</div>
|
||||
|
||||
{/* 카드 그리드 */}
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{/* 카드 1: 매입 */}
|
||||
<div
|
||||
style={{ backgroundColor: '#f5f3ff', borderColor: '#ddd6fe' }}
|
||||
|
||||
@@ -229,7 +229,7 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-sm min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<th className="px-4 py-2 text-left text-gray-600 font-medium">날짜</th>
|
||||
|
||||
@@ -234,7 +234,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-sm min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<th className="px-4 py-2 text-left text-gray-600 font-medium">날짜</th>
|
||||
|
||||
@@ -39,22 +39,12 @@ export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionPr
|
||||
})
|
||||
: items;
|
||||
|
||||
// 아이템 개수에 따른 동적 그리드 클래스 (xs: 344px Galaxy Fold 지원)
|
||||
const getGridColsClass = () => {
|
||||
const count = filteredItems.length;
|
||||
if (count <= 1) return 'grid-cols-1';
|
||||
if (count === 2) return 'grid-cols-1 xs:grid-cols-2';
|
||||
if (count === 3) return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-3';
|
||||
// 4개 이상: 최대 4열, 넘치면 아래로
|
||||
return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-4';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="현황판" badge="warning" />
|
||||
|
||||
<div className={`grid ${getGridColsClass()} gap-3`}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{filteredItems.map((item) => (
|
||||
<IssueCardItem
|
||||
key={item.id}
|
||||
|
||||
@@ -293,11 +293,11 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
|
||||
|
||||
{/* 날짜 네비게이션 (이전 이슈 탭일 때만) */}
|
||||
{activeTab === 'past' && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
className="h-8 px-1.5 shrink-0"
|
||||
onClick={handlePrevDate}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@@ -308,14 +308,14 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
|
||||
onChange={handleDatePickerChange}
|
||||
size="sm"
|
||||
displayFormat="yyyy년 MM월 dd일"
|
||||
className="w-[170px]"
|
||||
className="min-w-0 flex-1"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
className="h-8 px-1.5 shrink-0"
|
||||
onClick={handleNextDate}
|
||||
disabled={isNextDisabled}
|
||||
>
|
||||
@@ -326,7 +326,7 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
|
||||
|
||||
{/* 필터 */}
|
||||
<Select value={filter} onValueChange={setFilter}>
|
||||
<SelectTrigger className="w-44 h-9 ml-auto">
|
||||
<SelectTrigger className="w-44 h-9">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -78,7 +78,7 @@ export function UnshippedSection({ data }: UnshippedSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-sm min-w-[550px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<th className="px-4 py-2 text-center text-gray-600 font-medium w-12">No</th>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function VatSection({ data, onClick }: VatSectionProps) {
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="부가세 현황" badge="warning" />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from './types';
|
||||
import { getPositions, getDepartments, uploadProfileImage, type PositionItem, type DepartmentItem } from './actions';
|
||||
import { getProfileImageUrl } from './utils';
|
||||
import { extractDigits } from '@/lib/formatters';
|
||||
|
||||
// 부서 트리 구조 타입
|
||||
interface DepartmentTreeNode extends DepartmentItem {
|
||||
@@ -272,7 +273,7 @@ export function EmployeeForm({
|
||||
|
||||
// 휴대폰 번호 자동 하이픈 포맷팅
|
||||
const formatPhoneNumber = (value: string): string => {
|
||||
const numbers = value.replace(/[^0-9]/g, '');
|
||||
const numbers = extractDigits(value);
|
||||
if (numbers.length <= 3) return numbers;
|
||||
if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
|
||||
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}`;
|
||||
@@ -280,7 +281,7 @@ export function EmployeeForm({
|
||||
|
||||
// 주민등록번호 자동 하이픈 포맷팅
|
||||
const formatResidentNumber = (value: string): string => {
|
||||
const numbers = value.replace(/[^0-9]/g, '');
|
||||
const numbers = extractDigits(value);
|
||||
if (numbers.length <= 6) return numbers;
|
||||
return `${numbers.slice(0, 6)}-${numbers.slice(6, 13)}`;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { extractDigits } from '@/lib/formatters';
|
||||
|
||||
interface AddCompanyDialogProps {
|
||||
open: boolean;
|
||||
@@ -36,7 +37,7 @@ export function AddCompanyDialog({ open, onOpenChange }: AddCompanyDialogProps)
|
||||
|
||||
// 숫자만 입력 가능 (10자리 제한)
|
||||
const handleBusinessNumberChange = (value: string) => {
|
||||
const numbersOnly = value.replace(/[^0-9]/g, '');
|
||||
const numbersOnly = extractDigits(value);
|
||||
if (numbersOnly.length <= 10) {
|
||||
setBusinessNumber(numbersOnly);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PaymentApiData, PaymentHistory, PaymentStatus } from './types';
|
||||
import { PAYMENT_METHOD_LABELS } from './types';
|
||||
import { formatDate } from '@/lib/utils/date';
|
||||
import { formatDate, toDateString } from '@/lib/utils/date';
|
||||
|
||||
// ===== API → Frontend 변환 =====
|
||||
export function transformApiToFrontend(apiData: PaymentApiData): PaymentHistory {
|
||||
@@ -11,12 +11,12 @@ export function transformApiToFrontend(apiData: PaymentApiData): PaymentHistory
|
||||
const paymentMethodLabel = PAYMENT_METHOD_LABELS[apiData.payment_method] || apiData.payment_method;
|
||||
|
||||
// 구독 기간
|
||||
const periodStart = subscription?.started_at?.split('T')[0] || '';
|
||||
const periodEnd = subscription?.ended_at?.split('T')[0] || '';
|
||||
const periodStart = toDateString(subscription?.started_at);
|
||||
const periodEnd = toDateString(subscription?.ended_at);
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
paymentDate: apiData.paid_at?.split('T')[0] || formatDate(apiData.created_at),
|
||||
paymentDate: toDateString(apiData.paid_at) || formatDate(apiData.created_at),
|
||||
subscriptionName: plan?.name || '구독',
|
||||
paymentMethod: paymentMethodLabel,
|
||||
subscriptionPeriod: {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types';
|
||||
import { toDateString } from '@/lib/utils/date';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
@@ -55,9 +56,9 @@ export function transformApiToFrontend(apiData: PopupApiData): Popup {
|
||||
status: apiData.status as PopupStatus,
|
||||
author: apiData.creator?.name || '관리자',
|
||||
authorId: apiData.created_by ? String(apiData.created_by) : '',
|
||||
createdAt: apiData.created_at?.split('T')[0] || '',
|
||||
startDate: apiData.started_at?.split('T')[0] || '',
|
||||
endDate: apiData.ended_at?.split('T')[0] || '',
|
||||
createdAt: toDateString(apiData.created_at),
|
||||
startDate: toDateString(apiData.started_at),
|
||||
endDate: toDateString(apiData.ended_at),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,14 @@ export function parseNumber(formatted: string): number {
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자만 추출 (범용)
|
||||
* 전화번호, 사업자번호 등 포맷팅 전처리에 사용
|
||||
*/
|
||||
export function extractDigits(value: string): string {
|
||||
return value.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading zero 제거 (01 → 1)
|
||||
*/
|
||||
@@ -188,34 +196,3 @@ export function removeLeadingZeros(value: string): string {
|
||||
return value.replace(/^0+/, '') || '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자만 추출 (음수, 소수점 허용 옵션)
|
||||
*/
|
||||
export function extractNumbers(value: string, options?: {
|
||||
allowNegative?: boolean;
|
||||
allowDecimal?: boolean;
|
||||
}): string {
|
||||
const { allowNegative = false, allowDecimal = false } = options || {};
|
||||
|
||||
let pattern = '\\d';
|
||||
if (allowNegative) pattern = '-?' + pattern;
|
||||
if (allowDecimal) pattern = pattern + '|\\.';
|
||||
|
||||
const regex = new RegExp(`[^${allowNegative ? '-' : ''}${allowDecimal ? '.' : ''}\\d]`, 'g');
|
||||
let result = value.replace(regex, '');
|
||||
|
||||
// 중복 마이너스 제거 (첫 번째만 유지)
|
||||
if (allowNegative && result.includes('-')) {
|
||||
const isNegative = result.startsWith('-');
|
||||
result = result.replace(/-/g, '');
|
||||
if (isNegative) result = '-' + result;
|
||||
}
|
||||
|
||||
// 중복 소수점 제거 (첫 번째만 유지)
|
||||
if (allowDecimal && result.includes('.')) {
|
||||
const parts = result.split('.');
|
||||
result = parts[0] + (parts.length > 1 ? '.' + parts.slice(1).join('') : '');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -58,6 +58,17 @@ export function formatDateForInput(dateStr: string | null | undefined): string {
|
||||
return getLocalDateString(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 문자열에서 날짜 부분(YYYY-MM-DD)만 추출
|
||||
* null/undefined 시 빈 문자열 반환 (폼 데이터 변환용)
|
||||
* @example toDateString("2025-01-06T00:00:00.000Z") // "2025-01-06"
|
||||
* @example toDateString(null) // ""
|
||||
*/
|
||||
export function toDateString(isoString: string | null | undefined): string {
|
||||
if (!isoString) return '';
|
||||
return isoString.split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 표시용 포맷 (YYYY-MM-DD)
|
||||
* @example formatDate("2025-01-06T00:00:00.000Z") // "2025-01-06"
|
||||
|
||||
110
src/lib/utils/validation/common.ts
Normal file
110
src/lib/utils/validation/common.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 공통 Zod 검증 스키마
|
||||
*
|
||||
* 품목명, 품목유형, 날짜, 숫자, BOM 등 여러 스키마에서 공유하는 기본 블록
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ===== 내부 전용 스키마 =====
|
||||
|
||||
/**
|
||||
* 품목 코드 검증
|
||||
* 형식: {업체코드}-{품목유형}-{일련번호}
|
||||
* 예: KD-FG-001
|
||||
*
|
||||
* 현재 사용하지 않음 (품목 코드 자동 생성)
|
||||
*/
|
||||
export const _itemCodeSchema = z.string()
|
||||
.min(1, '품목 코드를 입력해주세요')
|
||||
.regex(
|
||||
/^[A-Z0-9]+-[A-Z]{2}-\d+$/,
|
||||
'품목 코드 형식이 올바르지 않습니다 (예: KD-FG-001)'
|
||||
);
|
||||
|
||||
// ===== 공통 필드 스키마 =====
|
||||
|
||||
/**
|
||||
* 품목명 검증
|
||||
*/
|
||||
export const itemNameSchema = z.preprocess(
|
||||
(val) => val === undefined || val === null ? "" : val,
|
||||
z.string().min(1, '품목명을 입력해주세요').max(200, '품목명은 200자 이내로 입력해주세요')
|
||||
);
|
||||
|
||||
/**
|
||||
* 품목 유형 검증
|
||||
*/
|
||||
export const itemTypeSchema = z.enum(['FG', 'PT', 'SM', 'RM', 'CS'], {
|
||||
message: '품목 유형을 선택해주세요',
|
||||
});
|
||||
|
||||
/**
|
||||
* 단위 검증
|
||||
*
|
||||
* 현재 사용하지 않음 (materialUnitSchema로 대체)
|
||||
*/
|
||||
export const _unitSchema = z.string()
|
||||
.min(1, '단위를 입력해주세요')
|
||||
.max(20, '단위는 20자 이내로 입력해주세요');
|
||||
|
||||
/**
|
||||
* 양수 검증 (가격, 수량 등)
|
||||
* undefined나 빈 문자열은 검증하지 않음
|
||||
*/
|
||||
export const positiveNumberSchema = z.union([
|
||||
z.number().positive('0보다 큰 값을 입력해주세요'),
|
||||
z.string().transform((val) => parseFloat(val)).pipe(z.number().positive('0보다 큰 값을 입력해주세요')),
|
||||
z.undefined(),
|
||||
z.null(),
|
||||
z.literal('')
|
||||
]).optional();
|
||||
|
||||
/**
|
||||
* 날짜 검증 (YYYY-MM-DD)
|
||||
* 빈 문자열이나 undefined는 검증하지 않음
|
||||
*/
|
||||
export const dateSchema = z.preprocess(
|
||||
(val) => {
|
||||
if (val === undefined || val === null || val === '') return undefined;
|
||||
return val;
|
||||
},
|
||||
z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, '날짜 형식이 올바르지 않습니다 (YYYY-MM-DD)')
|
||||
.optional()
|
||||
);
|
||||
|
||||
// ===== BOM 라인 스키마 =====
|
||||
|
||||
/**
|
||||
* 절곡품 전개도 상세 스키마
|
||||
*/
|
||||
export const bendingDetailSchema = z.object({
|
||||
id: z.string(),
|
||||
no: z.number().int().positive(),
|
||||
input: z.number(),
|
||||
elongation: z.number().default(-1),
|
||||
calculated: z.number(),
|
||||
sum: z.number(),
|
||||
shaded: z.boolean().default(false),
|
||||
aAngle: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* BOM 라인 스키마
|
||||
*/
|
||||
export const bomLineSchema = z.object({
|
||||
id: z.string(),
|
||||
childItemCode: z.string().min(1, '하위 품목 코드를 입력해주세요'),
|
||||
childItemName: z.string().min(1, '하위 품목명을 입력해주세요'),
|
||||
quantity: z.number().positive('수량은 0보다 커야 합니다'),
|
||||
unit: z.string().min(1, '단위를 입력해주세요'),
|
||||
unitPrice: positiveNumberSchema,
|
||||
quantityFormula: z.string().optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
|
||||
// 절곡품 관련
|
||||
isBending: z.boolean().optional(),
|
||||
bendingDiagram: z.string().url().optional(),
|
||||
bendingDetails: z.array(bendingDetailSchema).optional(),
|
||||
});
|
||||
191
src/lib/utils/validation/form-schemas.ts
Normal file
191
src/lib/utils/validation/form-schemas.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 폼 데이터 Zod 검증 스키마
|
||||
*
|
||||
* 품목 생성/수정/필터용 스키마
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { itemTypeSchema, bendingDetailSchema } from './common';
|
||||
import {
|
||||
productSchema,
|
||||
productSchemaBase,
|
||||
partSchemaBase,
|
||||
materialSchemaBase,
|
||||
materialSchema,
|
||||
consumableSchemaBase,
|
||||
consumableSchema,
|
||||
} from './item-schemas';
|
||||
|
||||
// ===== 폼 데이터 스키마 (생성/수정용) =====
|
||||
|
||||
/**
|
||||
* 품목 생성 폼 스키마
|
||||
* (id, createdAt, updatedAt 제외)
|
||||
*
|
||||
* discriminatedUnion은 omit()을 지원하지 않으므로,
|
||||
* 각 스키마에 대해 개별적으로 omit을 적용합니다.
|
||||
*/
|
||||
// partSchemaBase를 omit한 후 itemType merge - refinement는 마지막에 적용
|
||||
const partSchemaForForm = partSchemaBase
|
||||
.omit({ createdAt: true, updatedAt: true })
|
||||
.merge(z.object({ itemType: z.literal('PT') }))
|
||||
.superRefine((data, ctx) => {
|
||||
// 1단계: 부품 유형 필수
|
||||
if (!data.partType || data.partType === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '부품 유형을 선택해주세요',
|
||||
path: ['partType'],
|
||||
});
|
||||
return; // 부품 유형이 없으면 더 이상 검증하지 않음
|
||||
}
|
||||
|
||||
// 2단계: 부품 유형이 있을 때만 품목명 필수
|
||||
if (!data.category1 || data.category1 === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '품목명을 선택해주세요',
|
||||
path: ['category1'],
|
||||
});
|
||||
return; // 품목명이 없으면 더 이상 검증하지 않음 (설치유형 등 체크 안 함)
|
||||
}
|
||||
|
||||
// 3단계: 조립 부품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
|
||||
if (data.partType === 'ASSEMBLY') {
|
||||
if (!data.installationType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '설치 유형을 선택해주세요',
|
||||
path: ['installationType'],
|
||||
});
|
||||
}
|
||||
if (!data.sideSpecWidth) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '측면 규격 (가로)를 입력해주세요',
|
||||
path: ['sideSpecWidth'],
|
||||
});
|
||||
}
|
||||
if (!data.sideSpecHeight) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '측면 규격 (세로)를 입력해주세요',
|
||||
path: ['sideSpecHeight'],
|
||||
});
|
||||
}
|
||||
if (!data.assemblyLength) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '길이를 선택해주세요',
|
||||
path: ['assemblyLength'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 절곡품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
|
||||
if (data.partType === 'BENDING') {
|
||||
// 단계별 검증: 종류(category2) → 재질(material) → 폭 합계 → 모양&길이
|
||||
if (!data.category2 || data.category2 === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '종류를 선택해주세요',
|
||||
path: ['category2'],
|
||||
});
|
||||
return; // 종류가 없으면 재질, 폭 합계, 모양&길이 체크 안 함
|
||||
}
|
||||
if (!data.material) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '재질을 선택해주세요',
|
||||
path: ['material'],
|
||||
});
|
||||
return; // 재질이 없으면 폭 합계, 모양&길이 체크 안 함
|
||||
}
|
||||
if (!data.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '폭 합계를 입력해주세요',
|
||||
path: ['length'],
|
||||
});
|
||||
return; // 폭 합계가 없으면 모양&길이 체크 안 함
|
||||
}
|
||||
if (!data.bendingLength) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '모양&길이를 선택해주세요',
|
||||
path: ['bendingLength'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 구매 부품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
|
||||
if (data.partType === 'PURCHASED') {
|
||||
if (data.category1 === 'electric_opener') {
|
||||
if (!data.electricOpenerPower) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '전원을 선택해주세요',
|
||||
path: ['electricOpenerPower'],
|
||||
});
|
||||
}
|
||||
if (!data.electricOpenerCapacity) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '용량을 선택해주세요',
|
||||
path: ['electricOpenerCapacity'],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.category1 === 'motor' && !data.motorVoltage) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '전압을 선택해주세요',
|
||||
path: ['motorVoltage'],
|
||||
});
|
||||
}
|
||||
if (data.category1 === 'chain' && !data.chainSpec) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '체인 규격을 선택해주세요',
|
||||
path: ['chainSpec'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createItemFormSchema = z.discriminatedUnion('itemType', [
|
||||
productSchema.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('FG') }),
|
||||
partSchemaForForm, // itemType이 이미 merge되어 있음
|
||||
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('SM') }),
|
||||
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('RM') }),
|
||||
consumableSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('CS') }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* 품목 수정 폼 스키마
|
||||
* (모든 필드 선택적)
|
||||
*
|
||||
* discriminatedUnion은 partial()도 지원하지 않으므로,
|
||||
* 각 스키마에 대해 개별적으로 처리합니다.
|
||||
*/
|
||||
export const updateItemFormSchema = z.discriminatedUnion('itemType', [
|
||||
productSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('FG') }),
|
||||
partSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('PT') }),
|
||||
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('SM') }),
|
||||
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('RM') }),
|
||||
consumableSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('CS') }),
|
||||
]);
|
||||
|
||||
// ===== 필터 스키마 =====
|
||||
|
||||
/**
|
||||
* 품목 목록 필터 스키마
|
||||
*/
|
||||
export const itemFilterSchema = z.object({
|
||||
itemType: itemTypeSchema.optional(),
|
||||
search: z.string().optional(),
|
||||
category1: z.string().optional(),
|
||||
category2: z.string().optional(),
|
||||
category3: z.string().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
31
src/lib/utils/validation/index.ts
Normal file
31
src/lib/utils/validation/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Zod 검증 스키마 barrel export
|
||||
*
|
||||
* 기존 `@/lib/utils/validation` 경로 호환성 유지
|
||||
*/
|
||||
|
||||
// common
|
||||
export { bendingDetailSchema, bomLineSchema } from './common';
|
||||
|
||||
// item-schemas
|
||||
export {
|
||||
productSchema,
|
||||
partSchema,
|
||||
materialSchema,
|
||||
consumableSchema,
|
||||
itemMasterSchema,
|
||||
} from './item-schemas';
|
||||
|
||||
// form-schemas
|
||||
export { createItemFormSchema, updateItemFormSchema, itemFilterSchema } from './form-schemas';
|
||||
|
||||
// utils
|
||||
export { getSchemaByItemType, formatZodError } from './utils';
|
||||
export type {
|
||||
ItemMasterFormData,
|
||||
CreateItemFormData,
|
||||
UpdateItemFormData,
|
||||
ItemFilterFormData,
|
||||
BOMLineFormData,
|
||||
BendingDetailFormData,
|
||||
} from './utils';
|
||||
@@ -1,119 +1,25 @@
|
||||
/**
|
||||
* Zod 검증 스키마
|
||||
* 품목유형별 Zod 검증 스키마
|
||||
*
|
||||
* react-hook-form과 함께 사용하는 폼 검증
|
||||
* FG(제품), PT(부품), SM/RM(원자재/부자재), CS(소모품) 스키마 정의
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ItemType } from '@/types/item';
|
||||
|
||||
// ===== 공통 스키마 =====
|
||||
|
||||
/**
|
||||
* 품목 코드 검증
|
||||
* 형식: {업체코드}-{품목유형}-{일련번호}
|
||||
* 예: KD-FG-001
|
||||
*
|
||||
* 현재 사용하지 않음 (품목 코드 자동 생성)
|
||||
*/
|
||||
const _itemCodeSchema = z.string()
|
||||
.min(1, '품목 코드를 입력해주세요')
|
||||
.regex(
|
||||
/^[A-Z0-9]+-[A-Z]{2}-\d+$/,
|
||||
'품목 코드 형식이 올바르지 않습니다 (예: KD-FG-001)'
|
||||
);
|
||||
|
||||
/**
|
||||
* 품목명 검증
|
||||
*/
|
||||
const itemNameSchema = z.preprocess(
|
||||
(val) => val === undefined || val === null ? "" : val,
|
||||
z.string().min(1, '품목명을 입력해주세요').max(200, '품목명은 200자 이내로 입력해주세요')
|
||||
);
|
||||
|
||||
/**
|
||||
* 품목 유형 검증
|
||||
*/
|
||||
const itemTypeSchema = z.enum(['FG', 'PT', 'SM', 'RM', 'CS'], {
|
||||
message: '품목 유형을 선택해주세요',
|
||||
});
|
||||
|
||||
/**
|
||||
* 단위 검증
|
||||
*
|
||||
* 현재 사용하지 않음 (materialUnitSchema로 대체)
|
||||
*/
|
||||
const _unitSchema = z.string()
|
||||
.min(1, '단위를 입력해주세요')
|
||||
.max(20, '단위는 20자 이내로 입력해주세요');
|
||||
|
||||
/**
|
||||
* 양수 검증 (가격, 수량 등)
|
||||
* undefined나 빈 문자열은 검증하지 않음
|
||||
*/
|
||||
const positiveNumberSchema = z.union([
|
||||
z.number().positive('0보다 큰 값을 입력해주세요'),
|
||||
z.string().transform((val) => parseFloat(val)).pipe(z.number().positive('0보다 큰 값을 입력해주세요')),
|
||||
z.undefined(),
|
||||
z.null(),
|
||||
z.literal('')
|
||||
]).optional();
|
||||
|
||||
/**
|
||||
* 날짜 검증 (YYYY-MM-DD)
|
||||
* 빈 문자열이나 undefined는 검증하지 않음
|
||||
*/
|
||||
const dateSchema = z.preprocess(
|
||||
(val) => {
|
||||
if (val === undefined || val === null || val === '') return undefined;
|
||||
return val;
|
||||
},
|
||||
z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, '날짜 형식이 올바르지 않습니다 (YYYY-MM-DD)')
|
||||
.optional()
|
||||
);
|
||||
|
||||
// ===== BOM 라인 스키마 =====
|
||||
|
||||
/**
|
||||
* 절곡품 전개도 상세 스키마
|
||||
*/
|
||||
export const bendingDetailSchema = z.object({
|
||||
id: z.string(),
|
||||
no: z.number().int().positive(),
|
||||
input: z.number(),
|
||||
elongation: z.number().default(-1),
|
||||
calculated: z.number(),
|
||||
sum: z.number(),
|
||||
shaded: z.boolean().default(false),
|
||||
aAngle: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* BOM 라인 스키마
|
||||
*/
|
||||
export const bomLineSchema = z.object({
|
||||
id: z.string(),
|
||||
childItemCode: z.string().min(1, '하위 품목 코드를 입력해주세요'),
|
||||
childItemName: z.string().min(1, '하위 품목명을 입력해주세요'),
|
||||
quantity: z.number().positive('수량은 0보다 커야 합니다'),
|
||||
unit: z.string().min(1, '단위를 입력해주세요'),
|
||||
unitPrice: positiveNumberSchema,
|
||||
quantityFormula: z.string().optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
|
||||
// 절곡품 관련
|
||||
isBending: z.boolean().optional(),
|
||||
bendingDiagram: z.string().url().optional(),
|
||||
bendingDetails: z.array(bendingDetailSchema).optional(),
|
||||
});
|
||||
import {
|
||||
itemNameSchema,
|
||||
itemTypeSchema,
|
||||
dateSchema,
|
||||
positiveNumberSchema,
|
||||
bomLineSchema,
|
||||
bendingDetailSchema,
|
||||
} from './common';
|
||||
|
||||
// ===== 품목 마스터 기본 스키마 =====
|
||||
|
||||
/**
|
||||
* 품목 마스터 공통 필드
|
||||
*/
|
||||
const itemMasterBaseSchema = z.object({
|
||||
export const itemMasterBaseSchema = z.object({
|
||||
// 공통 필수 필드
|
||||
itemCode: z.string().optional(), // 자동생성되므로 선택 사항
|
||||
itemName: itemNameSchema,
|
||||
@@ -185,7 +91,7 @@ const productFieldsSchema = z.object({
|
||||
* 제품(FG) 전체 스키마 (refinement 없이)
|
||||
* 제품에는 가격 정보가 없으므로 제거
|
||||
*/
|
||||
const productSchemaBase = itemMasterBaseSchema
|
||||
export const productSchemaBase = itemMasterBaseSchema
|
||||
.omit({
|
||||
purchasePrice: true,
|
||||
salesPrice: true,
|
||||
@@ -268,7 +174,7 @@ const partFieldsSchema = z.object({
|
||||
* 부품(PT) 전체 스키마 (refinement 없이)
|
||||
* 부품은 itemName을 사용하지 않으므로 선택 사항으로 변경
|
||||
*/
|
||||
const partSchemaBase = itemMasterBaseSchema
|
||||
export const partSchemaBase = itemMasterBaseSchema
|
||||
.extend({
|
||||
itemName: z.string().max(200).optional(), // 부품은 itemName 선택 사항
|
||||
})
|
||||
@@ -422,7 +328,7 @@ const materialUnitSchema = z.preprocess(
|
||||
* 원자재/부자재 Base 스키마 (refinement 없음, 필드만 정의)
|
||||
* specification, unit을 필수로 정의 (z.object로 완전히 새로 정의)
|
||||
*/
|
||||
const materialSchemaBase = z.object({
|
||||
export const materialSchemaBase = z.object({
|
||||
// 공통 필수 필드
|
||||
itemCode: z.string().optional(),
|
||||
itemName: itemNameSchema,
|
||||
@@ -479,7 +385,7 @@ export const materialSchema = materialSchemaBase;
|
||||
* 소모품 Base 스키마
|
||||
* specification, unit을 필수로 오버라이드
|
||||
*/
|
||||
const consumableSchemaBase = itemMasterBaseSchema
|
||||
export const consumableSchemaBase = itemMasterBaseSchema
|
||||
.extend({
|
||||
specification: materialSpecificationSchema, // optional → 필수로 변경
|
||||
unit: materialUnitSchema, // optional → 필수로 변경
|
||||
@@ -505,221 +411,3 @@ export const itemMasterSchema = z.discriminatedUnion('itemType', [
|
||||
materialSchema.extend({ itemType: z.literal('RM') }),
|
||||
consumableSchema.extend({ itemType: z.literal('CS') }),
|
||||
]);
|
||||
|
||||
// ===== 폼 데이터 스키마 (생성/수정용) =====
|
||||
|
||||
/**
|
||||
* 품목 생성 폼 스키마
|
||||
* (id, createdAt, updatedAt 제외)
|
||||
*
|
||||
* discriminatedUnion은 omit()을 지원하지 않으므로,
|
||||
* 각 스키마에 대해 개별적으로 omit을 적용합니다.
|
||||
*/
|
||||
// partSchemaBase를 omit한 후 itemType merge - refinement는 마지막에 적용
|
||||
const partSchemaForForm = partSchemaBase
|
||||
.omit({ createdAt: true, updatedAt: true })
|
||||
.merge(z.object({ itemType: z.literal('PT') }))
|
||||
.superRefine((data, ctx) => {
|
||||
// 1단계: 부품 유형 필수
|
||||
if (!data.partType || data.partType === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '부품 유형을 선택해주세요',
|
||||
path: ['partType'],
|
||||
});
|
||||
return; // 부품 유형이 없으면 더 이상 검증하지 않음
|
||||
}
|
||||
|
||||
// 2단계: 부품 유형이 있을 때만 품목명 필수
|
||||
if (!data.category1 || data.category1 === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '품목명을 선택해주세요',
|
||||
path: ['category1'],
|
||||
});
|
||||
return; // 품목명이 없으면 더 이상 검증하지 않음 (설치유형 등 체크 안 함)
|
||||
}
|
||||
|
||||
// 3단계: 조립 부품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
|
||||
if (data.partType === 'ASSEMBLY') {
|
||||
if (!data.installationType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '설치 유형을 선택해주세요',
|
||||
path: ['installationType'],
|
||||
});
|
||||
}
|
||||
if (!data.sideSpecWidth) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '측면 규격 (가로)를 입력해주세요',
|
||||
path: ['sideSpecWidth'],
|
||||
});
|
||||
}
|
||||
if (!data.sideSpecHeight) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '측면 규격 (세로)를 입력해주세요',
|
||||
path: ['sideSpecHeight'],
|
||||
});
|
||||
}
|
||||
if (!data.assemblyLength) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '길이를 선택해주세요',
|
||||
path: ['assemblyLength'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 절곡품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
|
||||
if (data.partType === 'BENDING') {
|
||||
// 단계별 검증: 종류(category2) → 재질(material) → 폭 합계 → 모양&길이
|
||||
if (!data.category2 || data.category2 === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '종류를 선택해주세요',
|
||||
path: ['category2'],
|
||||
});
|
||||
return; // 종류가 없으면 재질, 폭 합계, 모양&길이 체크 안 함
|
||||
}
|
||||
if (!data.material) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '재질을 선택해주세요',
|
||||
path: ['material'],
|
||||
});
|
||||
return; // 재질이 없으면 폭 합계, 모양&길이 체크 안 함
|
||||
}
|
||||
if (!data.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '폭 합계를 입력해주세요',
|
||||
path: ['length'],
|
||||
});
|
||||
return; // 폭 합계가 없으면 모양&길이 체크 안 함
|
||||
}
|
||||
if (!data.bendingLength) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '모양&길이를 선택해주세요',
|
||||
path: ['bendingLength'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 구매 부품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
|
||||
if (data.partType === 'PURCHASED') {
|
||||
if (data.category1 === 'electric_opener') {
|
||||
if (!data.electricOpenerPower) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '전원을 선택해주세요',
|
||||
path: ['electricOpenerPower'],
|
||||
});
|
||||
}
|
||||
if (!data.electricOpenerCapacity) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '용량을 선택해주세요',
|
||||
path: ['electricOpenerCapacity'],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.category1 === 'motor' && !data.motorVoltage) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '전압을 선택해주세요',
|
||||
path: ['motorVoltage'],
|
||||
});
|
||||
}
|
||||
if (data.category1 === 'chain' && !data.chainSpec) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '체인 규격을 선택해주세요',
|
||||
path: ['chainSpec'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createItemFormSchema = z.discriminatedUnion('itemType', [
|
||||
productSchema.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('FG') }),
|
||||
partSchemaForForm, // itemType이 이미 merge되어 있음
|
||||
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('SM') }),
|
||||
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('RM') }),
|
||||
consumableSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('CS') }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* 품목 수정 폼 스키마
|
||||
* (모든 필드 선택적)
|
||||
*
|
||||
* discriminatedUnion은 partial()도 지원하지 않으므로,
|
||||
* 각 스키마에 대해 개별적으로 처리합니다.
|
||||
*/
|
||||
export const updateItemFormSchema = z.discriminatedUnion('itemType', [
|
||||
productSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('FG') }),
|
||||
partSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('PT') }),
|
||||
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('SM') }),
|
||||
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('RM') }),
|
||||
consumableSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('CS') }),
|
||||
]);
|
||||
|
||||
// ===== 필터 스키마 =====
|
||||
|
||||
/**
|
||||
* 품목 목록 필터 스키마
|
||||
*/
|
||||
export const itemFilterSchema = z.object({
|
||||
itemType: itemTypeSchema.optional(),
|
||||
search: z.string().optional(),
|
||||
category1: z.string().optional(),
|
||||
category2: z.string().optional(),
|
||||
category3: z.string().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ===== 타입 추출 =====
|
||||
|
||||
export type ItemMasterFormData = z.infer<typeof itemMasterSchema>;
|
||||
export type CreateItemFormData = z.infer<typeof createItemFormSchema>;
|
||||
export type UpdateItemFormData = z.infer<typeof updateItemFormSchema>;
|
||||
export type ItemFilterFormData = z.infer<typeof itemFilterSchema>;
|
||||
export type BOMLineFormData = z.infer<typeof bomLineSchema>;
|
||||
export type BendingDetailFormData = z.infer<typeof bendingDetailSchema>;
|
||||
|
||||
// ===== 유틸리티 함수 =====
|
||||
|
||||
/**
|
||||
* 품목 유형에 따른 스키마 선택
|
||||
*/
|
||||
export function getSchemaByItemType(itemType: ItemType) {
|
||||
switch (itemType) {
|
||||
case 'FG':
|
||||
return productSchema;
|
||||
case 'PT':
|
||||
return partSchema;
|
||||
case 'SM':
|
||||
case 'RM':
|
||||
return materialSchema;
|
||||
case 'CS':
|
||||
return consumableSchema;
|
||||
default:
|
||||
return itemMasterBaseSchema;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 메시지 한글화
|
||||
*/
|
||||
export function formatZodError(error: z.ZodError): Record<string, string> {
|
||||
const formatted: Record<string, string> = {};
|
||||
|
||||
error.issues.forEach((err) => {
|
||||
const path = err.path.join('.');
|
||||
formatted[path] = err.message;
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
64
src/lib/utils/validation/utils.ts
Normal file
64
src/lib/utils/validation/utils.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 검증 유틸리티 함수 및 타입 추출
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ItemType } from '@/types/item';
|
||||
import { bendingDetailSchema, bomLineSchema } from './common';
|
||||
import {
|
||||
productSchema,
|
||||
partSchema,
|
||||
materialSchema,
|
||||
consumableSchema,
|
||||
itemMasterSchema,
|
||||
itemMasterBaseSchema,
|
||||
} from './item-schemas';
|
||||
import {
|
||||
createItemFormSchema,
|
||||
updateItemFormSchema,
|
||||
itemFilterSchema,
|
||||
} from './form-schemas';
|
||||
|
||||
// ===== 타입 추출 =====
|
||||
|
||||
export type ItemMasterFormData = z.infer<typeof itemMasterSchema>;
|
||||
export type CreateItemFormData = z.infer<typeof createItemFormSchema>;
|
||||
export type UpdateItemFormData = z.infer<typeof updateItemFormSchema>;
|
||||
export type ItemFilterFormData = z.infer<typeof itemFilterSchema>;
|
||||
export type BOMLineFormData = z.infer<typeof bomLineSchema>;
|
||||
export type BendingDetailFormData = z.infer<typeof bendingDetailSchema>;
|
||||
|
||||
// ===== 유틸리티 함수 =====
|
||||
|
||||
/**
|
||||
* 품목 유형에 따른 스키마 선택
|
||||
*/
|
||||
export function getSchemaByItemType(itemType: ItemType) {
|
||||
switch (itemType) {
|
||||
case 'FG':
|
||||
return productSchema;
|
||||
case 'PT':
|
||||
return partSchema;
|
||||
case 'SM':
|
||||
case 'RM':
|
||||
return materialSchema;
|
||||
case 'CS':
|
||||
return consumableSchema;
|
||||
default:
|
||||
return itemMasterBaseSchema;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 메시지 한글화
|
||||
*/
|
||||
export function formatZodError(error: z.ZodError): Record<string, string> {
|
||||
const formatted: Record<string, string> = {};
|
||||
|
||||
error.issues.forEach((err) => {
|
||||
const path = err.path.join('.');
|
||||
formatted[path] = err.message;
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
Reference in New Issue
Block a user