- 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>
414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
/**
|
|
* 품목유형별 Zod 검증 스키마
|
|
*
|
|
* FG(제품), PT(부품), SM/RM(원자재/부자재), CS(소모품) 스키마 정의
|
|
*/
|
|
|
|
import { z } from 'zod';
|
|
import {
|
|
itemNameSchema,
|
|
itemTypeSchema,
|
|
dateSchema,
|
|
positiveNumberSchema,
|
|
bomLineSchema,
|
|
bendingDetailSchema,
|
|
} from './common';
|
|
|
|
// ===== 품목 마스터 기본 스키마 =====
|
|
|
|
/**
|
|
* 품목 마스터 공통 필드
|
|
*/
|
|
export 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 없이)
|
|
* 제품에는 가격 정보가 없으므로 제거
|
|
*/
|
|
export 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을 사용하지 않으므로 선택 사항으로 변경
|
|
*/
|
|
export 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로 완전히 새로 정의)
|
|
*/
|
|
export 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을 필수로 오버라이드
|
|
*/
|
|
export 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') }),
|
|
]);
|