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,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;