Files
sam-react-prod/src/lib/utils/validation.ts
byeongcheolryu ded0bc2439 fix: TypeScript 타입 오류 수정 및 설정 페이지 추가
- BOMItem Omit 타입 시그니처 통일 (useTemplateManagement, SectionsTab, ItemMasterContext)
- HeadersInit → Record<string, string> 타입 변경
- Zustand useShallow 마이그레이션 (zustand/react/shallow)
- DataTable, ListPageTemplate 제네릭 타입 제약 추가
- 설정 관리 페이지 추가 (직급, 직책, 휴가정책, 근무일정, 권한)
- HR 관리 페이지 추가 (급여, 휴가)
- 단가관리 페이지 리팩토링

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 18:07:47 +09:00

725 lines
22 KiB
TypeScript

/**
* Zod 검증 스키마
*
* react-hook-form과 함께 사용하는 폼 검증
*/
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(),
});
// ===== 품목 마스터 기본 스키마 =====
/**
* 품목 마스터 공통 필드
*/
const itemMasterBaseSchema = z.object({
// 공통 필수 필드
itemCode: z.string().optional(), // 자동생성되므로 선택 사항
itemName: itemNameSchema,
itemType: itemTypeSchema,
unit: z.string().max(20).optional(), // 선택 사항으로 변경
// 공통 선택 필드
specification: z.string().max(500).optional(),
isActive: z.boolean().default(true),
// 분류
category1: z.string().max(100).optional(),
category2: z.string().max(100).optional(),
category3: z.string().max(100).optional(),
// 가격 정보 - 모두 선택 사항
purchasePrice: z.number().optional(),
salesPrice: z.number().optional(),
marginRate: z.number().min(0).max(100).optional(),
processingCost: z.number().optional(),
laborCost: z.number().optional(),
installCost: z.number().optional(),
// BOM
bom: z.array(bomLineSchema).optional(),
bomCategories: z.array(z.string()).optional(),
// 메타데이터
safetyStock: z.number().int().nonnegative().optional(),
leadTime: z.number().int().nonnegative().optional(),
isVariableSize: z.boolean().optional(),
// 버전 관리
currentRevision: z.number().int().nonnegative().default(0),
isFinal: z.boolean().default(false),
finalizedDate: dateSchema,
finalizedBy: z.string().optional(),
// 시스템 필드
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
});
// ===== 제품(FG) 스키마 =====
/**
* 제품 전용 필드
*/
const productFieldsSchema = z.object({
productName: z.preprocess(
(val) => val === undefined || val === null ? "" : val,
z.string().min(1, '상품명을 입력해주세요').max(200, '상품명은 200자 이내로 입력해주세요')
),
productCategory: z.enum(['SCREEN', 'STEEL']).optional(),
lotAbbreviation: z.string().max(10).optional(),
note: z.string().max(1000).optional(),
// 인정 정보
certificationNumber: z.string().max(100).optional(),
certificationStartDate: dateSchema,
certificationEndDate: dateSchema,
specificationFile: z.string().optional(),
specificationFileName: z.string().optional(),
certificationFile: z.string().optional(),
certificationFileName: z.string().optional(),
});
/**
* 제품(FG) 전체 스키마 (refinement 없이)
* 제품에는 가격 정보가 없으므로 제거
*/
const productSchemaBase = itemMasterBaseSchema
.omit({
purchasePrice: true,
salesPrice: true,
processingCost: true,
laborCost: true,
installCost: true,
})
.merge(productFieldsSchema);
/**
* 제품(FG) 전체 스키마 (refinement 포함)
*/
export const productSchema = productSchemaBase.refine(
(data) => {
// 인정 정보 검증: 시작일과 종료일이 모두 있거나 모두 없어야 함
if (data.certificationStartDate && data.certificationEndDate) {
return new Date(data.certificationStartDate) <= new Date(data.certificationEndDate);
}
return true;
},
{
message: '인정 유효기간 종료일은 시작일보다 이후여야 합니다',
path: ['certificationEndDate'],
}
);
// ===== 부품(PT) 스키마 =====
/**
* 부품 전용 필드
*/
const partFieldsSchema = z.object({
partType: z.preprocess(
(val) => val === undefined || val === null ? "" : val,
z.string()
.min(1, '부품 유형을 선택해주세요')
.refine(
(val) => ['ASSEMBLY', 'BENDING', 'PURCHASED'].includes(val),
{ message: '부품 유형을 선택해주세요' }
)
),
partUsage: z.enum(['GUIDE_RAIL', 'BOTTOM_FINISH', 'CASE', 'DOOR', 'BRACKET', 'GENERAL']).optional(),
// 조립 부품
installationType: z.string().max(50).optional(),
assemblyType: z.string().max(50).optional(),
sideSpecWidth: z.string().max(50).optional(),
sideSpecHeight: z.string().max(50).optional(),
assemblyLength: z.string().max(50).optional(),
// 가이드레일
guideRailModelType: z.string().max(100).optional(),
guideRailModel: z.string().max(100).optional(),
// 절곡품
bendingDiagram: z.string().url().optional(),
bendingDetails: z.array(bendingDetailSchema).optional(),
material: z.string().max(100).optional(),
length: z.string().max(50).optional(),
bendingLength: z.string().max(50).optional(),
// 구매 부품
electricOpenerPower: z.string().max(50).optional(),
electricOpenerCapacity: z.string().max(50).optional(),
motorVoltage: z.string().max(50).optional(),
motorCapacity: z.string().max(50).optional(),
chainSpec: z.string().max(100).optional(),
// 인정 정보 (부품도 인정 가능)
certificationNumber: z.string().max(100).optional(),
certificationStartDate: dateSchema,
certificationEndDate: dateSchema,
specificationFile: z.string().optional(),
specificationFileName: z.string().optional(),
certificationFile: z.string().optional(),
certificationFileName: z.string().optional(),
});
/**
* 부품(PT) 전체 스키마 (refinement 없이)
* 부품은 itemName을 사용하지 않으므로 선택 사항으로 변경
*/
const partSchemaBase = itemMasterBaseSchema
.extend({
itemName: z.string().max(200).optional(), // 부품은 itemName 선택 사항
})
.merge(partFieldsSchema);
/**
* 부품(PT) 전체 스키마 (refinement 포함)
*/
export const partSchema = partSchemaBase
.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'],
});
}
// 3단계: 조립 부품 전용 필드
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'],
});
}
}
// 절곡품 전용 필드
if (data.partType === 'BENDING') {
if (!data.material) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '재질을 선택해주세요',
path: ['material'],
});
}
if (!data.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '폭 합계를 입력해주세요',
path: ['length'],
});
}
if (!data.bendingLength) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '모양&길이를 선택해주세요',
path: ['bendingLength'],
});
}
}
// 구매 부품 전용 필드
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'],
});
}
}
});
// ===== 원자재/부자재(RM/SM) 스키마 =====
/**
* 원자재/부자재 전용 필드
*
* 현재 사용하지 않음 (materialSchemaBase에 직접 포함됨)
*/
const _materialFieldsSchema = z.object({
material: z.string().max(100).optional(),
length: z.string().max(50).optional(),
});
/**
* 원자재/부자재 규격 필수 필드 스키마
*/
const materialSpecificationSchema = z.preprocess(
(val) => val === undefined || val === null ? "" : val,
z.string().min(1, '규격을 입력해주세요').max(500, '최대 500자')
);
/**
* 원자재/부자재 단위 필수 필드 스키마
*/
const materialUnitSchema = z.preprocess(
(val) => val === undefined || val === null ? "" : val,
z.string().min(1, '단위를 입력해주세요').max(20, '최대 20자')
);
/**
* 원자재/부자재 Base 스키마 (refinement 없음, 필드만 정의)
* specification, unit을 필수로 정의 (z.object로 완전히 새로 정의)
*/
const materialSchemaBase = z.object({
// 공통 필수 필드
itemCode: z.string().optional(),
itemName: itemNameSchema,
itemType: itemTypeSchema,
specification: materialSpecificationSchema, // 필수!
unit: materialUnitSchema, // 필수!
isActive: z.boolean().default(true),
// 분류
category1: z.string().max(100).optional(),
category2: z.string().max(100).optional(),
category3: z.string().max(100).optional(),
// 가격 정보
purchasePrice: z.number().optional(),
salesPrice: z.number().optional(),
marginRate: z.number().min(0).max(100).optional(),
processingCost: z.number().optional(),
laborCost: z.number().optional(),
installCost: z.number().optional(),
// BOM
bom: z.array(bomLineSchema).optional(),
bomCategories: z.array(z.string()).optional(),
// 메타데이터
safetyStock: z.number().int().nonnegative().optional(),
leadTime: z.number().int().nonnegative().optional(),
isVariableSize: z.boolean().optional(),
// 버전 관리
currentRevision: z.number().int().nonnegative().default(0),
isFinal: z.boolean().default(false),
finalizedDate: dateSchema,
finalizedBy: z.string().optional(),
// 시스템 필드
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
// 원자재/부자재 전용 필드
material: z.string().max(100).optional(),
length: z.string().max(50).optional(),
});
/**
* 원자재/부자재 전체 스키마 (export용)
*/
export const materialSchema = materialSchemaBase;
// ===== 소모품(CS) 스키마 =====
/**
* 소모품 Base 스키마
* specification, unit을 필수로 오버라이드
*/
const consumableSchemaBase = itemMasterBaseSchema
.extend({
specification: materialSpecificationSchema, // optional → 필수로 변경
unit: materialUnitSchema, // optional → 필수로 변경
});
/**
* 소모품 전체 스키마 (export용)
*/
export const consumableSchema = consumableSchemaBase;
// ===== 통합 품목 스키마 =====
/**
* 품목 유형에 따른 동적 검증
*
* Zod 4.x에서는 refinement가 있는 스키마를 extend할 수 없으므로,
* refinement가 없는 base 스키마를 사용합니다.
*/
export const itemMasterSchema = z.discriminatedUnion('itemType', [
productSchemaBase.extend({ itemType: z.literal('FG') }),
partSchemaBase.extend({ itemType: z.literal('PT') }),
materialSchema.extend({ itemType: z.literal('SM') }),
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;
}