feat(WEB): DevToolbar - 견적→수주→작업지시→출하 테스트 자동화 도구

- DevFillContext: 전역 상태 관리 (활성화/페이지 타입/폼 채우기 함수)
- DevToolbar: 플로팅 UI 컴포넌트 (토글/자동 채우기 버튼)
- useDevFill: 각 폼에서 자동 채우기 함수 등록 커스텀 훅
- 데이터 생성기: 견적/수주/작업지시/출하 샘플 데이터
- 환경변수 제어: NEXT_PUBLIC_DEV_TOOLBAR_ENABLED로 On/Off
- 통합: QuoteRegistration, OrderRegistration, WorkOrderCreate, ShipmentCreate
- Hydration 불일치 방지: useState 초기값 false + useEffect 패턴
This commit is contained in:
2026-01-20 20:38:29 +09:00
parent c101b8bf7e
commit eae23d4457
15 changed files with 1048 additions and 5 deletions

View File

@@ -0,0 +1,79 @@
/**
* 샘플 데이터 생성 공통 유틸리티
*/
// 랜덤 선택
export function randomPick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
// 범위 내 랜덤 정수
export function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// 범위 내 랜덤 정수 (100 단위)
export function randomInt100(min: number, max: number): number {
const minH = Math.ceil(min / 100);
const maxH = Math.floor(max / 100);
return randomInt(minH, maxH) * 100;
}
// 랜덤 전화번호
export function randomPhone(): string {
const middle = randomInt(1000, 9999);
const last = randomInt(1000, 9999);
return `010-${middle}-${last}`;
}
// 오늘 기준 N일 후 날짜
export function dateAfterDays(days: number): string {
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString().split('T')[0];
}
// 오늘 날짜
export function today(): string {
return new Date().toISOString().split('T')[0];
}
// 랜덤 층수
export function randomFloor(): string {
const floors = ['B1', '1F', '2F', '3F', '4F', '5F', '6F', '7F', '8F', '9F', '10F'];
return randomPick(floors);
}
// 순차 부호 생성 (F001, F002, ...)
let codeCounter = 1;
export function nextCode(): string {
const code = `F${String(codeCounter).padStart(3, '0')}`;
codeCounter++;
return code;
}
// 부호 카운터 리셋
export function resetCodeCounter(): void {
codeCounter = 1;
}
// 랜덤 비고
export function randomRemark(): string {
const remarks = [
'특이사항 없음',
'긴급 배송 요청',
'우천 시 배송 연기',
'오전 중 배송 희망',
'현장 담당자 부재 시 경비실 전달',
'설치 시 안전관리자 필요',
'화물용 엘리베이터 사용 가능',
'주차 공간 협소, 사전 연락 필수',
'',
];
return randomPick(remarks);
}
// 랜덤 ID 생성 (임시용)
export function tempId(): string {
return `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

View File

@@ -0,0 +1,99 @@
/**
* 수주 샘플 데이터 생성기
*
* 수주는 대부분 견적에서 전환되므로,
* 견적 선택 후 자동 채움되는 필드 외의 추가 정보만 생성
*/
import {
randomPick,
randomPhone,
dateAfterDays,
randomRemark,
} from './index';
import type { OrderFormData } from '@/components/orders/OrderRegistration';
// 배송방식
const DELIVERY_METHODS = ['direct', 'pickup', 'courier'];
// 운임비용
const SHIPPING_COSTS = ['free', 'prepaid', 'collect', 'negotiable'];
/**
* 수주 추가 정보 생성 (견적 전환 후 채울 필드들)
*/
export interface GenerateOrderDataOptions {
// 견적에서 가져온 기본 정보 (이미 채워진 상태)
baseData?: Partial<OrderFormData>;
}
export function generateOrderData(options: GenerateOrderDataOptions = {}): Partial<OrderFormData> {
const { baseData = {} } = options;
// 견적에서 전환된 경우 이미 채워진 필드들은 그대로 유지
// 추가로 채워야 할 필드들만 생성
return {
...baseData,
// 배송 정보
expectedShipDate: dateAfterDays(14), // 2주 후
expectedShipDateUndecided: false,
deliveryRequestDate: dateAfterDays(21), // 3주 후
deliveryRequestDateUndecided: false,
deliveryMethod: randomPick(DELIVERY_METHODS),
shippingCost: randomPick(SHIPPING_COSTS),
// 수신자 정보 (견적의 담당자 정보와 다를 수 있음)
receiver: baseData.manager || randomPick(['김수신', '이수신', '박수신']),
receiverContact: baseData.contact || randomPhone(),
// 주소 (테스트용 기본값)
zipCode: '06234',
address: '서울특별시 강남구 테헤란로 123',
addressDetail: '삼성빌딩 10층',
// 비고
remarks: randomRemark(),
};
}
/**
* 견적 없이 수주 직접 생성 시 사용
*/
export function generateOrderDataFull(): OrderFormData {
return {
// 기본 정보
clientId: '',
clientName: '테스트 거래처',
siteName: '테스트 현장',
manager: randomPick(['김담당', '이담당', '박담당']),
contact: randomPhone(),
// 배송 정보
expectedShipDate: dateAfterDays(14),
expectedShipDateUndecided: false,
deliveryRequestDate: dateAfterDays(21),
deliveryRequestDateUndecided: false,
deliveryMethod: randomPick(DELIVERY_METHODS),
shippingCost: randomPick(SHIPPING_COSTS),
receiver: randomPick(['김수신', '이수신', '박수신']),
receiverContact: randomPhone(),
// 주소
zipCode: '06234',
address: '서울특별시 강남구 테헤란로 123',
addressDetail: '삼성빌딩 10층',
// 비고
remarks: randomRemark(),
// 품목 (빈 배열 - 견적 선택 또는 수동 추가 필요)
items: [],
// 금액
subtotal: 0,
discountRate: 0,
totalAmount: 0,
};
}

View File

@@ -0,0 +1,126 @@
/**
* 견적 샘플 데이터 생성기
*/
import {
randomPick,
randomInt,
randomInt100,
randomPhone,
dateAfterDays,
today,
randomFloor,
nextCode,
resetCodeCounter,
randomRemark,
tempId,
} from './index';
import type { QuoteFormData, QuoteItem } from '@/components/quotes/QuoteRegistration';
import type { Vendor } from '@/components/accounting/VendorManagement';
import type { FinishedGoods } from '@/components/quotes/actions';
// 제품 카테고리
const PRODUCT_CATEGORIES = ['SCREEN', 'STEEL'];
// 가이드레일 설치 유형
const GUIDE_RAIL_TYPES = ['wall', 'floor'];
// 모터 전원
const MOTOR_POWERS = ['single', 'three'];
// 연동제어기
const CONTROLLERS = ['basic', 'smart', 'premium'];
// 작성자 목록 (실제로는 로그인 사용자 사용)
const WRITERS = ['드미트리', '김철수', '이영희', '박지민', '최서연'];
/**
* 견적 품목 1개 생성
*/
export function generateQuoteItem(
index: number,
products?: FinishedGoods[]
): QuoteItem {
const category = randomPick(PRODUCT_CATEGORIES);
// 카테고리에 맞는 제품 필터링
let productName = '';
if (products && products.length > 0) {
const categoryProducts = products.filter(p =>
p.categoryCode?.toUpperCase() === category || !p.categoryCode
);
if (categoryProducts.length > 0) {
productName = randomPick(categoryProducts).name;
}
}
// 제품명이 없으면 기본값
if (!productName) {
productName = category === 'SCREEN'
? randomPick(['방화스크린 FSC-1', '방화스크린 FSC-2', '방화스크린 FSC-3'])
: randomPick(['방화철재셔터 FSD-1', '방화철재셔터 FSD-2']);
}
return {
id: tempId(),
floor: randomFloor(),
code: nextCode(),
productCategory: category,
productName,
openWidth: String(randomInt100(2000, 5000)),
openHeight: String(randomInt100(2000, 5000)),
guideRailType: randomPick(GUIDE_RAIL_TYPES),
motorPower: randomPick(MOTOR_POWERS),
controller: randomPick(CONTROLLERS),
quantity: randomInt(1, 10),
wingSize: '50',
inspectionFee: 50000,
};
}
/**
* 견적 폼 데이터 생성
*/
export interface GenerateQuoteDataOptions {
clients?: Vendor[]; // 거래처 목록
products?: FinishedGoods[]; // 제품 목록
itemCount?: number; // 품목 수 (기본: 1~5개 랜덤)
}
export function generateQuoteData(options: GenerateQuoteDataOptions = {}): QuoteFormData {
const { clients = [], products = [], itemCount } = options;
// 부호 카운터 리셋
resetCodeCounter();
// 거래처 선택
let clientId = '';
let clientName = '';
if (clients.length > 0) {
const client = randomPick(clients);
clientId = String(client.id);
clientName = client.name;
}
// 품목 수 결정
const count = itemCount ?? randomInt(1, 5);
// 품목 생성
const items: QuoteItem[] = [];
for (let i = 0; i < count; i++) {
items.push(generateQuoteItem(i, products));
}
return {
registrationDate: today(),
writer: randomPick(WRITERS),
clientId,
clientName,
siteName: clientName ? `${clientName} 현장` : '테스트 현장',
manager: randomPick(['김담당', '이담당', '박담당', '최담당']),
contact: randomPhone(),
dueDate: dateAfterDays(7), // 1주일 후
remarks: randomRemark(),
items,
};
}

View File

@@ -0,0 +1,77 @@
/**
* 출하 샘플 데이터 생성기
*/
import {
randomPick,
today,
randomRemark,
} from './index';
import type {
ShipmentCreateFormData,
ShipmentPriority,
DeliveryMethod,
LotOption,
LogisticsOption,
VehicleTonnageOption,
} from '@/components/outbound/ShipmentManagement/types';
// 우선순위
const PRIORITIES: ShipmentPriority[] = ['urgent', 'normal', 'low'];
// 배송방식
const DELIVERY_METHODS: DeliveryMethod[] = ['pickup', 'direct', 'logistics'];
/**
* 출하 폼 데이터 생성
*/
export interface GenerateShipmentDataOptions {
lotOptions?: LotOption[]; // 로트 목록
logisticsOptions?: LogisticsOption[]; // 물류사 목록
tonnageOptions?: VehicleTonnageOption[]; // 차량 톤수 목록
lotNo?: string; // 지정 로트번호 (플로우에서 전달)
}
export function generateShipmentData(
options: GenerateShipmentDataOptions = {}
): ShipmentCreateFormData {
const {
lotOptions = [],
logisticsOptions = [],
tonnageOptions = [],
lotNo,
} = options;
// 로트 선택
let selectedLotNo = lotNo || '';
if (!selectedLotNo && lotOptions.length > 0) {
selectedLotNo = randomPick(lotOptions).lotNo;
}
// 배송방식
const deliveryMethod = randomPick(DELIVERY_METHODS);
// 물류사 (물류사 배송일 때만)
let logisticsCompany = '';
if (deliveryMethod === 'logistics' && logisticsOptions.length > 0) {
logisticsCompany = randomPick(logisticsOptions).name;
}
// 차량 톤수
let vehicleTonnage = '';
if (tonnageOptions.length > 0) {
vehicleTonnage = randomPick(tonnageOptions).value;
}
return {
lotNo: selectedLotNo,
scheduledDate: today(),
priority: randomPick(PRIORITIES),
deliveryMethod,
logisticsCompany,
vehicleTonnage,
loadingTime: '',
loadingManager: '',
remarks: randomRemark(),
};
}

View File

@@ -0,0 +1,46 @@
/**
* 작업지시 샘플 데이터 생성기
*
* 작업지시는 수주 선택 후 자동 채움되는 필드 외의 추가 정보만 생성
*/
import {
randomPick,
randomInt,
dateAfterDays,
randomRemark,
} from './index';
import type { ProcessOption } from '@/components/production/WorkOrders/actions';
/**
* 작업지시 추가 정보 생성
*/
export interface GenerateWorkOrderDataOptions {
processOptions?: ProcessOption[]; // 공정 목록
}
export interface WorkOrderFormDataPartial {
processId: number | null;
shipmentDate: string;
priority: number;
note: string;
}
export function generateWorkOrderData(
options: GenerateWorkOrderDataOptions = {}
): WorkOrderFormDataPartial {
const { processOptions = [] } = options;
// 공정 선택 (있으면 랜덤 선택, 없으면 null)
let processId: number | null = null;
if (processOptions.length > 0) {
processId = randomPick(processOptions).id;
}
return {
processId,
shipmentDate: dateAfterDays(randomInt(7, 21)), // 1~3주 후
priority: randomPick([3, 5, 7]), // 높음(3), 보통(5), 낮음(7)
note: randomRemark(),
};
}