diff --git a/.env.example b/.env.example index 72380b1d..d1e65ed6 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,15 @@ NEXT_PUBLIC_AUTH_MODE=sanctum # - 외부 시스템 연동 API_KEY=your-secret-api-key-here +# ============================================== +# Development Tools +# ============================================== +# DevToolbar: 개발/테스트용 폼 자동 채우기 도구 +# - true: 활성화 (화면 하단에 플로팅 툴바 표시) +# - false 또는 미설정: 비활성화 +# 주의: 운영 환경에서는 반드시 false로 설정! +NEXT_PUBLIC_DEV_TOOLBAR_ENABLED=false + # ============================================== # Development Notes # ============================================== diff --git a/src/app/[locale]/(protected)/layout.tsx b/src/app/[locale]/(protected)/layout.tsx index 27efc25e..45753012 100644 --- a/src/app/[locale]/(protected)/layout.tsx +++ b/src/app/[locale]/(protected)/layout.tsx @@ -5,6 +5,7 @@ import AuthenticatedLayout from '@/layouts/AuthenticatedLayout'; import { RootProvider } from '@/contexts/RootProvider'; import { ApiErrorProvider } from '@/contexts/ApiErrorContext'; import { FCMProvider } from '@/contexts/FCMProvider'; +import { DevFillProvider, DevToolbar } from '@/components/dev'; /** * Protected Layout @@ -40,7 +41,10 @@ export default function ProtectedLayout({ - {children} + + {children} + + diff --git a/src/components/dev/DevFillContext.tsx b/src/components/dev/DevFillContext.tsx new file mode 100644 index 00000000..a75abb7f --- /dev/null +++ b/src/components/dev/DevFillContext.tsx @@ -0,0 +1,175 @@ +'use client'; + +/** + * DevFill Context + * + * 개발/테스트용 폼 자동 채우기 기능을 위한 Context + * - 각 페이지 컴포넌트에서 폼 채우기 함수를 등록 + * - DevToolbar에서 등록된 함수를 호출 + * + * 사용법: + * 1. 각 폼 컴포넌트에서 useDevFill hook으로 fillForm 함수 등록 + * 2. DevToolbar에서 버튼 클릭 시 해당 함수 호출 + */ + +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; + +// 지원하는 페이지 타입 +export type DevFillPageType = 'quote' | 'order' | 'workOrder' | 'workOrderComplete' | 'shipment'; + +// 폼 채우기 함수 타입 +type FillFormFunction = (data?: unknown) => void | Promise; + +// Context 타입 +interface DevFillContextType { + // 현재 활성화 상태 + isEnabled: boolean; + setIsEnabled: (enabled: boolean) => void; + + // 툴바 표시 상태 + isVisible: boolean; + setIsVisible: (visible: boolean) => void; + + // 현재 페이지 타입 + currentPage: DevFillPageType | null; + setCurrentPage: (page: DevFillPageType | null) => void; + + // 폼 채우기 함수 등록/해제 + registerFillForm: (pageType: DevFillPageType, fillFn: FillFormFunction) => void; + unregisterFillForm: (pageType: DevFillPageType) => void; + + // 폼 채우기 실행 + fillForm: (pageType: DevFillPageType, data?: unknown) => Promise; + + // 등록된 페이지 확인 + hasRegisteredForm: (pageType: DevFillPageType) => boolean; + + // 플로우 데이터 (이전 단계에서 생성된 ID 저장) + flowData: FlowData; + setFlowData: (data: Partial) => void; + clearFlowData: () => void; +} + +// 플로우 간 전달 데이터 +interface FlowData { + quoteId?: number; + orderId?: number; + workOrderId?: number; + lotNo?: string; +} + +const DevFillContext = createContext(null); + +// Provider Props +interface DevFillProviderProps { + children: ReactNode; +} + +export function DevFillProvider({ children }: DevFillProviderProps) { + // 활성화 상태 (환경변수 기반) - 초기값 false로 서버/클라이언트 일치 + const [isEnabled, setIsEnabled] = useState(false); + + // 툴바 표시 상태 (localStorage 기반) - 초기값 false로 서버/클라이언트 일치 + const [isVisible, setIsVisible] = useState(false); + + // 클라이언트 마운트 후 실제 값 설정 (Hydration 불일치 방지) + useEffect(() => { + setIsEnabled(process.env.NEXT_PUBLIC_DEV_TOOLBAR_ENABLED === 'true'); + const stored = localStorage.getItem('devToolbarVisible'); + setIsVisible(stored !== 'false'); + }, []); + + // 현재 페이지 타입 + const [currentPage, setCurrentPage] = useState(null); + + // 등록된 폼 채우기 함수들 + const [fillFunctions, setFillFunctions] = useState>(new Map()); + + // 플로우 데이터 + const [flowData, setFlowDataState] = useState({}); + + // 툴바 표시 상태 저장 + const handleSetIsVisible = useCallback((visible: boolean) => { + setIsVisible(visible); + if (typeof window !== 'undefined') { + localStorage.setItem('devToolbarVisible', String(visible)); + } + }, []); + + // 폼 채우기 함수 등록 + const registerFillForm = useCallback((pageType: DevFillPageType, fillFn: FillFormFunction) => { + setFillFunctions(prev => { + const next = new Map(prev); + next.set(pageType, fillFn); + return next; + }); + }, []); + + // 폼 채우기 함수 해제 + const unregisterFillForm = useCallback((pageType: DevFillPageType) => { + setFillFunctions(prev => { + const next = new Map(prev); + next.delete(pageType); + return next; + }); + }, []); + + // 폼 채우기 실행 + const fillForm = useCallback(async (pageType: DevFillPageType, data?: unknown) => { + const fillFn = fillFunctions.get(pageType); + if (fillFn) { + await fillFn(data); + } else { + console.warn(`[DevFill] No fill function registered for page: ${pageType}`); + } + }, [fillFunctions]); + + // 등록 여부 확인 + const hasRegisteredForm = useCallback((pageType: DevFillPageType) => { + return fillFunctions.has(pageType); + }, [fillFunctions]); + + // 플로우 데이터 설정 + const setFlowData = useCallback((data: Partial) => { + setFlowDataState(prev => ({ ...prev, ...data })); + }, []); + + // 플로우 데이터 초기화 + const clearFlowData = useCallback(() => { + setFlowDataState({}); + }, []); + + // 비활성화 시 null 반환하지 않고 children만 렌더링 + // (DevToolbar에서 isEnabled 체크) + + return ( + {}, // 환경변수로 제어하므로 런타임 변경 불가 + isVisible, + setIsVisible: handleSetIsVisible, + currentPage, + setCurrentPage, + registerFillForm, + unregisterFillForm, + fillForm, + hasRegisteredForm, + flowData, + setFlowData, + clearFlowData, + }} + > + {children} + + ); +} + +// Hook +export function useDevFillContext() { + const context = useContext(DevFillContext); + if (!context) { + throw new Error('useDevFillContext must be used within a DevFillProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/components/dev/DevToolbar.tsx b/src/components/dev/DevToolbar.tsx new file mode 100644 index 00000000..fb30d581 --- /dev/null +++ b/src/components/dev/DevToolbar.tsx @@ -0,0 +1,235 @@ +'use client'; + +/** + * DevToolbar - 개발/테스트용 플로팅 툴바 + * + * 화면 하단에 플로팅으로 표시되며, + * 각 단계(견적→수주→작업지시→완료→출하)의 폼을 자동으로 채울 수 있습니다. + * + * 환경변수로 활성화/비활성화: + * NEXT_PUBLIC_DEV_TOOLBAR_ENABLED=true + */ + +import { useState } from 'react'; +import { usePathname } from 'next/navigation'; +import { + FileText, // 견적 + ClipboardList, // 수주 + Wrench, // 작업지시 + CheckCircle2, // 완료 + Truck, // 출하 + ChevronDown, + ChevronUp, + X, + Loader2, + Play, + RotateCcw, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useDevFillContext, type DevFillPageType } from './DevFillContext'; + +// 페이지 경로와 타입 매핑 +const PAGE_PATTERNS: { pattern: RegExp; type: DevFillPageType; label: string }[] = [ + { pattern: /\/quote-management\/new/, type: 'quote', label: '견적' }, + { pattern: /\/quote-management\/\d+\/edit/, type: 'quote', label: '견적' }, + { pattern: /\/order-management-sales\/new/, type: 'order', label: '수주' }, + { pattern: /\/order-management-sales\/\d+\/edit/, type: 'order', label: '수주' }, + { pattern: /\/work-orders\/create/, type: 'workOrder', label: '작업지시' }, + { pattern: /\/work-orders\/\d+\/edit/, type: 'workOrder', label: '작업지시' }, + { pattern: /\/work-orders\/\d+$/, type: 'workOrderComplete', label: '작업완료' }, + { pattern: /\/shipments\/new/, type: 'shipment', label: '출하' }, + { pattern: /\/shipments\/\d+\/edit/, type: 'shipment', label: '출하' }, +]; + +// 플로우 단계 정의 +const FLOW_STEPS: { type: DevFillPageType; label: string; icon: typeof FileText; path: string }[] = [ + { type: 'quote', label: '견적', icon: FileText, path: '/sales/quote-management/new' }, + { type: 'order', label: '수주', icon: ClipboardList, path: '/sales/order-management-sales/new' }, + { type: 'workOrder', label: '작업지시', icon: Wrench, path: '/production/work-orders/create' }, + { type: 'workOrderComplete', label: '완료', icon: CheckCircle2, path: '' }, // 상세 페이지에서 처리 + { type: 'shipment', label: '출하', icon: Truck, path: '/outbound/shipments/new' }, +]; + +export function DevToolbar() { + const pathname = usePathname(); + const { + isEnabled, + isVisible, + setIsVisible, + currentPage, + fillForm, + hasRegisteredForm, + flowData, + clearFlowData, + } = useDevFillContext(); + + const [isExpanded, setIsExpanded] = useState(true); + const [isLoading, setIsLoading] = useState(null); + + // 비활성화 시 렌더링하지 않음 + if (!isEnabled) return null; + + // 숨김 상태일 때 작은 버튼만 표시 + if (!isVisible) { + return ( +
+ +
+ ); + } + + // 현재 페이지 타입 감지 + const detectedPage = PAGE_PATTERNS.find(p => p.pattern.test(pathname)); + const activePage = detectedPage?.type || null; + + // 폼 채우기 실행 + const handleFillForm = async (pageType: DevFillPageType) => { + if (!hasRegisteredForm(pageType)) { + console.warn(`[DevToolbar] Form not registered for: ${pageType}`); + return; + } + + setIsLoading(pageType); + try { + await fillForm(pageType, flowData); + } catch (err) { + console.error('[DevToolbar] Fill form error:', err); + } finally { + setIsLoading(null); + } + }; + + // 플로우 데이터 표시 + const hasFlowData = flowData.quoteId || flowData.orderId || flowData.workOrderId || flowData.lotNo; + + return ( +
+
+ {/* 헤더 */} +
+
+ + DEV MODE + + {detectedPage && ( + + 현재: {detectedPage.label} + + )} + {hasFlowData && ( + + {flowData.quoteId && `견적#${flowData.quoteId}`} + {flowData.orderId && ` → 수주#${flowData.orderId}`} + {flowData.workOrderId && ` → 작업#${flowData.workOrderId}`} + + )} +
+
+ {hasFlowData && ( + + )} + + +
+
+ + {/* 버튼 영역 */} + {isExpanded && ( +
+ {FLOW_STEPS.map((step, index) => { + const Icon = step.icon; + const isActive = activePage === step.type; + const isRegistered = hasRegisteredForm(step.type); + const isCurrentLoading = isLoading === step.type; + + // 완료 버튼은 상세 페이지에서만 활성화 + if (step.type === 'workOrderComplete' && !isActive) { + return ( +
+ {index > 0 && } + +
+ ); + } + + return ( +
+ {index > 0 && } + +
+ ); + })} +
+ )} + + {/* 안내 메시지 */} + {isExpanded && !activePage && ( +
+

+ 견적/수주/작업지시/출하 페이지에서 활성화됩니다 +

+
+ )} +
+
+ ); +} + +export default DevToolbar; \ No newline at end of file diff --git a/src/components/dev/generators/index.ts b/src/components/dev/generators/index.ts new file mode 100644 index 00000000..3c7ba787 --- /dev/null +++ b/src/components/dev/generators/index.ts @@ -0,0 +1,79 @@ +/** + * 샘플 데이터 생성 공통 유틸리티 + */ + +// 랜덤 선택 +export function randomPick(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)}`; +} \ No newline at end of file diff --git a/src/components/dev/generators/orderData.ts b/src/components/dev/generators/orderData.ts new file mode 100644 index 00000000..35543e77 --- /dev/null +++ b/src/components/dev/generators/orderData.ts @@ -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; +} + +export function generateOrderData(options: GenerateOrderDataOptions = {}): Partial { + 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, + }; +} \ No newline at end of file diff --git a/src/components/dev/generators/quoteData.ts b/src/components/dev/generators/quoteData.ts new file mode 100644 index 00000000..f0b8b32b --- /dev/null +++ b/src/components/dev/generators/quoteData.ts @@ -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, + }; +} \ No newline at end of file diff --git a/src/components/dev/generators/shipmentData.ts b/src/components/dev/generators/shipmentData.ts new file mode 100644 index 00000000..cbcb30c0 --- /dev/null +++ b/src/components/dev/generators/shipmentData.ts @@ -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(), + }; +} \ No newline at end of file diff --git a/src/components/dev/generators/workOrderData.ts b/src/components/dev/generators/workOrderData.ts new file mode 100644 index 00000000..d1468981 --- /dev/null +++ b/src/components/dev/generators/workOrderData.ts @@ -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(), + }; +} \ No newline at end of file diff --git a/src/components/dev/index.ts b/src/components/dev/index.ts new file mode 100644 index 00000000..cb0e1f6f --- /dev/null +++ b/src/components/dev/index.ts @@ -0,0 +1,16 @@ +/** + * Dev 컴포넌트 모듈 + * + * 개발/테스트용 도구 모음 + * 환경변수 NEXT_PUBLIC_DEV_TOOLBAR_ENABLED=true 로 활성화 + */ + +export { DevFillProvider, useDevFillContext, type DevFillPageType } from './DevFillContext'; +export { useDevFill } from './useDevFill'; +export { DevToolbar } from './DevToolbar'; + +// Generators +export { generateQuoteData, generateQuoteItem } from './generators/quoteData'; +export { generateOrderData, generateOrderDataFull } from './generators/orderData'; +export { generateWorkOrderData } from './generators/workOrderData'; +export { generateShipmentData } from './generators/shipmentData'; \ No newline at end of file diff --git a/src/components/dev/useDevFill.ts b/src/components/dev/useDevFill.ts new file mode 100644 index 00000000..1e235191 --- /dev/null +++ b/src/components/dev/useDevFill.ts @@ -0,0 +1,77 @@ +'use client'; + +/** + * useDevFill Hook + * + * 각 폼 컴포넌트에서 DevFill 기능을 사용하기 위한 hook + * + * 사용법: + * ```tsx + * function MyFormComponent() { + * const [formData, setFormData] = useState(initialData); + * + * useDevFill('quote', (data) => { + * setFormData(data as QuoteFormData); + * }); + * + * return
...
; + * } + * ``` + */ + +import { useEffect, useCallback } from 'react'; +import { useDevFillContext, type DevFillPageType } from './DevFillContext'; + +type FillFormCallback = (data: T) => void | Promise; + +/** + * DevFill hook + * + * @param pageType - 현재 페이지 타입 + * @param onFill - 데이터 채우기 콜백 (DevToolbar에서 호출됨) + */ +export function useDevFill( + pageType: DevFillPageType, + onFill: FillFormCallback +) { + const { + isEnabled, + setCurrentPage, + registerFillForm, + unregisterFillForm, + flowData, + } = useDevFillContext(); + + // 안정적인 콜백 참조 + const stableFillCallback = useCallback( + (data?: unknown) => { + return onFill(data as T); + }, + [onFill] + ); + + // 컴포넌트 마운트 시 등록, 언마운트 시 해제 + useEffect(() => { + if (!isEnabled) return; + + // 현재 페이지 설정 + setCurrentPage(pageType); + + // 폼 채우기 함수 등록 + registerFillForm(pageType, stableFillCallback); + + return () => { + // 클린업: 현재 페이지 해제 및 함수 등록 해제 + setCurrentPage(null); + unregisterFillForm(pageType); + }; + }, [isEnabled, pageType, setCurrentPage, registerFillForm, unregisterFillForm, stableFillCallback]); + + // 플로우 데이터 반환 (이전 단계에서 생성된 ID 등) + return { + isEnabled, + flowData, + }; +} + +export default useDevFill; \ No newline at end of file diff --git a/src/components/orders/OrderRegistration.tsx b/src/components/orders/OrderRegistration.tsx index c02530d9..214c2a20 100644 --- a/src/components/orders/OrderRegistration.tsx +++ b/src/components/orders/OrderRegistration.tsx @@ -55,6 +55,8 @@ import { type QuotationForSelect, type QuotationItem } from "./actions"; import { ItemAddDialog, OrderItem } from "./ItemAddDialog"; import { formatAmount } from "@/utils/formatAmount"; import { cn } from "@/lib/utils"; +import { useDevFill } from "@/components/dev"; +import { generateOrderDataFull } from "@/components/dev/generators/orderData"; // 수주 폼 데이터 타입 export interface OrderFormData { @@ -209,6 +211,24 @@ export function OrderRegistration({ })); }, [form.items, form.discountRate]); + // DevToolbar 자동 채우기 + useDevFill( + 'order', + useCallback(() => { + const sampleData = generateOrderDataFull(); + + // 거래처 목록에서 실제 데이터 사용 + if (clients.length > 0) { + const randomClient = clients[Math.floor(Math.random() * clients.length)]; + sampleData.clientId = randomClient.id; + sampleData.clientName = randomClient.name; + } + + setForm(sampleData); + toast.success('[Dev] 수주 폼이 자동으로 채워졌습니다.'); + }, [clients]) + ); + // 견적 선택 핸들러 const handleQuotationSelect = (quotation: QuotationForSelect) => { // 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가) diff --git a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx index 08972674..66046086 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx @@ -37,6 +37,9 @@ import type { VehicleTonnageOption, } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; +import { toast } from 'sonner'; +import { useDevFill } from '@/components/dev'; +import { generateShipmentData } from '@/components/dev/generators/shipmentData'; // 고정 옵션 (클라이언트에서 관리) const priorityOptions: { value: ShipmentPriority; label: string }[] = [ @@ -115,6 +118,38 @@ export function ShipmentCreate() { loadOptions(); }, [loadOptions]); + // DevToolbar 자동 채우기 + useDevFill( + 'shipment', + useCallback(() => { + // lotOptions를 generateShipmentData에 전달하기 위해 변환 + const lotOptionsForGenerator = lotOptions.map(o => ({ + lotNo: o.value, + customerName: o.customerName, + siteName: o.siteName, + })); + + const logisticsOptionsForGenerator = logisticsOptions.map(o => ({ + id: o.value, + name: o.label, + })); + + const tonnageOptionsForGenerator = vehicleTonnageOptions.map(o => ({ + value: o.value, + label: o.label, + })); + + const sampleData = generateShipmentData({ + lotOptions: lotOptionsForGenerator, + logisticsOptions: logisticsOptionsForGenerator, + tonnageOptions: tonnageOptionsForGenerator, + }); + + setFormData(sampleData); + toast.success('[Dev] 출하 폼이 자동으로 채워졌습니다.'); + }, [lotOptions, logisticsOptions, vehicleTonnageOptions]) + ); + // 폼 입력 핸들러 const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => { setFormData(prev => ({ ...prev, [field]: value })); diff --git a/src/components/production/WorkOrders/WorkOrderCreate.tsx b/src/components/production/WorkOrders/WorkOrderCreate.tsx index 21cced1f..43500941 100644 --- a/src/components/production/WorkOrders/WorkOrderCreate.tsx +++ b/src/components/production/WorkOrders/WorkOrderCreate.tsx @@ -8,7 +8,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { X, Edit2, FileText } from 'lucide-react'; +import { ArrowLeft, FileText, X, Edit2, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -28,9 +28,12 @@ import { AssigneeSelectModal } from './AssigneeSelectModal'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { createWorkOrder, getProcessOptions, type ProcessOption } from './actions'; -import { type SalesOrder } from './types'; +import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types'; import { workOrderCreateConfig } from './workOrderConfig'; +import { useDevFill } from '@/components/dev'; +import { generateWorkOrderData } from '@/components/dev/generators/workOrderData'; + // Validation 에러 타입 interface ValidationErrors { [key: string]: string; @@ -113,6 +116,32 @@ export function WorkOrderCreate() { loadProcessOptions(); }, []); + // DevToolbar 자동 채우기 + useDevFill( + 'workOrder', + useCallback(() => { + const sampleData = generateWorkOrderData({ processOptions }); + + // 수동 등록 모드로 변경 + setMode('manual'); + + // 폼 데이터 채우기 + setFormData(prev => ({ + ...prev, + client: '테스트 거래처', + projectName: '테스트 현장', + orderNo: '', + itemCount: 0, + processId: sampleData.processId, + shipmentDate: sampleData.shipmentDate, + priority: sampleData.priority, + note: sampleData.note, + })); + + toast.success('[Dev] 작업지시 폼이 자동으로 채워졌습니다.'); + }, [processOptions]) + ); + // 수주 선택 핸들러 const handleSelectOrder = (order: SalesOrder) => { setFormData({ diff --git a/src/components/quotes/QuoteRegistration.tsx b/src/components/quotes/QuoteRegistration.tsx index 9a926850..426a91bf 100644 --- a/src/components/quotes/QuoteRegistration.tsx +++ b/src/components/quotes/QuoteRegistration.tsx @@ -8,7 +8,7 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { Input } from "../ui/input"; import { Textarea } from "../ui/textarea"; import { @@ -55,6 +55,8 @@ import { getClients } from "../accounting/VendorManagement/actions"; import { isNextRedirectError } from "@/lib/utils/redirect-error"; import type { Vendor } from "../accounting/VendorManagement"; import type { BomMaterial, CalculationResults } from "./types"; +import { useDevFill } from "@/components/dev"; +import { generateQuoteData } from "@/components/dev/generators/quoteData"; // 견적 항목 타입 export interface QuoteItem { @@ -197,6 +199,20 @@ export function QuoteRegistration({ // 현장명 자동완성 목록 상태 const [siteNames, setSiteNames] = useState([]); + // DevToolbar용 폼 자동 채우기 등록 + useDevFill( + 'quote', + useCallback(() => { + // 실제 로드된 데이터를 기반으로 샘플 데이터 생성 + const sampleData = generateQuoteData({ + clients: clients.map(c => ({ id: c.id, name: c.vendorName })), + products: finishedGoods.map(p => ({ code: p.item_code, name: p.item_name, category: p.category })), + }); + setFormData(sampleData); + toast.success('[Dev] 견적 폼이 자동으로 채워졌습니다.'); + }, [clients, finishedGoods]) + ); + // 수량 반영 총합계 계산 (useMemo로 최적화) const calculatedGrandTotal = useMemo(() => { if (!calculationResults?.items) return 0; @@ -1103,7 +1119,7 @@ export function QuoteRegistration({
- 단가: {itemResult.result.grand_total.toLocaleString()}원 + 단가: {(itemResult.result.grand_total || 0).toLocaleString()}원
합계: {((itemResult.result.grand_total || 0) * (formItem?.quantity || 1)).toLocaleString()}원