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:
@@ -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
|
||||
# ==============================================
|
||||
|
||||
@@ -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({
|
||||
<RootProvider>
|
||||
<ApiErrorProvider>
|
||||
<FCMProvider>
|
||||
<AuthenticatedLayout>{children}</AuthenticatedLayout>
|
||||
<DevFillProvider>
|
||||
<AuthenticatedLayout>{children}</AuthenticatedLayout>
|
||||
<DevToolbar />
|
||||
</DevFillProvider>
|
||||
</FCMProvider>
|
||||
</ApiErrorProvider>
|
||||
</RootProvider>
|
||||
|
||||
175
src/components/dev/DevFillContext.tsx
Normal file
175
src/components/dev/DevFillContext.tsx
Normal file
@@ -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<void>;
|
||||
|
||||
// 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<void>;
|
||||
|
||||
// 등록된 페이지 확인
|
||||
hasRegisteredForm: (pageType: DevFillPageType) => boolean;
|
||||
|
||||
// 플로우 데이터 (이전 단계에서 생성된 ID 저장)
|
||||
flowData: FlowData;
|
||||
setFlowData: (data: Partial<FlowData>) => void;
|
||||
clearFlowData: () => void;
|
||||
}
|
||||
|
||||
// 플로우 간 전달 데이터
|
||||
interface FlowData {
|
||||
quoteId?: number;
|
||||
orderId?: number;
|
||||
workOrderId?: number;
|
||||
lotNo?: string;
|
||||
}
|
||||
|
||||
const DevFillContext = createContext<DevFillContextType | null>(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<DevFillPageType | null>(null);
|
||||
|
||||
// 등록된 폼 채우기 함수들
|
||||
const [fillFunctions, setFillFunctions] = useState<Map<DevFillPageType, FillFormFunction>>(new Map());
|
||||
|
||||
// 플로우 데이터
|
||||
const [flowData, setFlowDataState] = useState<FlowData>({});
|
||||
|
||||
// 툴바 표시 상태 저장
|
||||
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<FlowData>) => {
|
||||
setFlowDataState(prev => ({ ...prev, ...data }));
|
||||
}, []);
|
||||
|
||||
// 플로우 데이터 초기화
|
||||
const clearFlowData = useCallback(() => {
|
||||
setFlowDataState({});
|
||||
}, []);
|
||||
|
||||
// 비활성화 시 null 반환하지 않고 children만 렌더링
|
||||
// (DevToolbar에서 isEnabled 체크)
|
||||
|
||||
return (
|
||||
<DevFillContext.Provider
|
||||
value={{
|
||||
isEnabled,
|
||||
setIsEnabled: () => {}, // 환경변수로 제어하므로 런타임 변경 불가
|
||||
isVisible,
|
||||
setIsVisible: handleSetIsVisible,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
registerFillForm,
|
||||
unregisterFillForm,
|
||||
fillForm,
|
||||
hasRegisteredForm,
|
||||
flowData,
|
||||
setFlowData,
|
||||
clearFlowData,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DevFillContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook
|
||||
export function useDevFillContext() {
|
||||
const context = useContext(DevFillContext);
|
||||
if (!context) {
|
||||
throw new Error('useDevFillContext must be used within a DevFillProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
235
src/components/dev/DevToolbar.tsx
Normal file
235
src/components/dev/DevToolbar.tsx
Normal file
@@ -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<DevFillPageType | null>(null);
|
||||
|
||||
// 비활성화 시 렌더링하지 않음
|
||||
if (!isEnabled) return null;
|
||||
|
||||
// 숨김 상태일 때 작은 버튼만 표시
|
||||
if (!isVisible) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[9999]">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-yellow-100 border-yellow-400 text-yellow-800 hover:bg-yellow-200 shadow-lg"
|
||||
onClick={() => setIsVisible(true)}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
Dev
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 현재 페이지 타입 감지
|
||||
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 (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[9999]">
|
||||
<div className="bg-yellow-50 border-2 border-yellow-400 rounded-lg shadow-2xl overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-yellow-100 border-b border-yellow-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-yellow-200 border-yellow-500 text-yellow-800">
|
||||
DEV MODE
|
||||
</Badge>
|
||||
{detectedPage && (
|
||||
<span className="text-sm text-yellow-700">
|
||||
현재: <strong>{detectedPage.label}</strong>
|
||||
</span>
|
||||
)}
|
||||
{hasFlowData && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{flowData.quoteId && `견적#${flowData.quoteId}`}
|
||||
{flowData.orderId && ` → 수주#${flowData.orderId}`}
|
||||
{flowData.workOrderId && ` → 작업#${flowData.workOrderId}`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{hasFlowData && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 text-yellow-700 hover:bg-yellow-200"
|
||||
onClick={clearFlowData}
|
||||
title="플로우 초기화"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 text-yellow-700 hover:bg-yellow-200"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 text-yellow-700 hover:bg-yellow-200"
|
||||
onClick={() => setIsVisible(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
{isExpanded && (
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
{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 (
|
||||
<div key={step.type} className="flex items-center">
|
||||
{index > 0 && <span className="text-yellow-400 mx-1">→</span>}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="opacity-50"
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-1" />
|
||||
{step.label}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={step.type} className="flex items-center">
|
||||
{index > 0 && <span className="text-yellow-400 mx-1">→</span>}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
disabled={!isActive || !isRegistered || isCurrentLoading}
|
||||
className={
|
||||
isActive
|
||||
? 'bg-yellow-500 hover:bg-yellow-600 text-white border-yellow-600'
|
||||
: 'border-yellow-300 text-yellow-700 hover:bg-yellow-100'
|
||||
}
|
||||
onClick={() => handleFillForm(step.type)}
|
||||
>
|
||||
{isCurrentLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Icon className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{step.label}
|
||||
{isActive && ' 채우기'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
{isExpanded && !activePage && (
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-xs text-yellow-600">
|
||||
견적/수주/작업지시/출하 페이지에서 활성화됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DevToolbar;
|
||||
79
src/components/dev/generators/index.ts
Normal file
79
src/components/dev/generators/index.ts
Normal 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)}`;
|
||||
}
|
||||
99
src/components/dev/generators/orderData.ts
Normal file
99
src/components/dev/generators/orderData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
126
src/components/dev/generators/quoteData.ts
Normal file
126
src/components/dev/generators/quoteData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
77
src/components/dev/generators/shipmentData.ts
Normal file
77
src/components/dev/generators/shipmentData.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
46
src/components/dev/generators/workOrderData.ts
Normal file
46
src/components/dev/generators/workOrderData.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
16
src/components/dev/index.ts
Normal file
16
src/components/dev/index.ts
Normal file
@@ -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';
|
||||
77
src/components/dev/useDevFill.ts
Normal file
77
src/components/dev/useDevFill.ts
Normal file
@@ -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 <form>...</form>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useDevFillContext, type DevFillPageType } from './DevFillContext';
|
||||
|
||||
type FillFormCallback<T = unknown> = (data: T) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* DevFill hook
|
||||
*
|
||||
* @param pageType - 현재 페이지 타입
|
||||
* @param onFill - 데이터 채우기 콜백 (DevToolbar에서 호출됨)
|
||||
*/
|
||||
export function useDevFill<T = unknown>(
|
||||
pageType: DevFillPageType,
|
||||
onFill: FillFormCallback<T>
|
||||
) {
|
||||
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;
|
||||
@@ -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) => {
|
||||
// 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가)
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
|
||||
// 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({
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
단가: {itemResult.result.grand_total.toLocaleString()}원
|
||||
단가: {(itemResult.result.grand_total || 0).toLocaleString()}원
|
||||
</div>
|
||||
<div className="font-semibold text-green-700">
|
||||
합계: {((itemResult.result.grand_total || 0) * (formItem?.quantity || 1)).toLocaleString()}원
|
||||
|
||||
Reference in New Issue
Block a user