Files
sam-react-prod/src/components/dev/DevToolbar.tsx
권혁성 92af11c787 feat(WEB): FCM 푸시 알림, 입금 등록, 견적 저장 개선
- 수주 상세 페이지에서 수주확정 시 FCM 푸시 알림 발송 추가
- FCM 프리셋 함수 추가: 계약완료, 발주완료 알림
- 입금 등록 시 입금일, 입금계좌, 입금자명, 입금금액 입력 가능
- 견적 저장 시 토스트 메시지 정상 표시 수정
- ShipmentCreate SelectItem key prop 경고 수정
- DevToolbar 문법 오류 수정
2026-01-22 19:31:19 +09:00

340 lines
13 KiB
TypeScript

'use client';
/**
* DevToolbar - 개발/테스트용 플로팅 툴바
*
* 화면 하단에 플로팅으로 표시되며,
* 각 단계(견적→수주→작업지시→완료→출하)의 폼을 자동으로 채울 수 있습니다.
*
* 기능:
* - 현재 페이지에서 활성화: 버튼 클릭 시 폼 자동 채우기
* - 다른 페이지에서 비활성화: 버튼 클릭 시 해당 페이지로 이동
*
* 환경변수로 활성화/비활성화:
* NEXT_PUBLIC_DEV_TOOLBAR_ENABLED=true
*/
import { useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
FileText, // 견적
ClipboardList, // 수주
Wrench, // 작업지시
CheckCircle2, // 완료
Truck, // 출하
ChevronDown,
ChevronUp,
X,
Loader2,
Play,
RotateCcw,
// 회계 아이콘
ArrowDownToLine, // 입금
ArrowUpFromLine, // 출금
Receipt, // 매입(지출결의서)
CreditCard, // 카드
} 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: '출하' },
// 회계 플로우
{ pattern: /\/accounting\/deposits\/new/, type: 'deposit', label: '입금' },
{ pattern: /\/accounting\/withdrawals\/new/, type: 'withdrawal', label: '출금' },
{ pattern: /\/approval\/draft\/new/, type: 'purchaseApproval', label: '매입' },
{ pattern: /\/accounting\/card-transactions/, type: 'cardTransaction', 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' },
];
// 회계 단계 정의
const ACCOUNTING_STEPS: { type: DevFillPageType; label: string; icon: typeof FileText; path: string; fillEnabled: boolean }[] = [
{ type: 'deposit', label: '입금', icon: ArrowDownToLine, path: '/accounting/deposits/new', fillEnabled: true },
{ type: 'withdrawal', label: '출금', icon: ArrowUpFromLine, path: '/accounting/withdrawals/new', fillEnabled: true },
{ type: 'purchaseApproval', label: '매입', icon: Receipt, path: '/approval/draft/new', fillEnabled: true },
{ type: 'cardTransaction', label: '카드', icon: CreditCard, path: '/accounting/card-transactions', fillEnabled: false }, // 이동만
];
export function DevToolbar() {
const pathname = usePathname();
const router = useRouter();
const {
isEnabled,
isVisible,
setIsVisible,
currentPage,
fillForm,
hasRegisteredForm,
flowData,
clearFlowData,
} = useDevFillContext();
const [isExpanded, setIsExpanded] = useState(true);
const [isLoading, setIsLoading] = useState<DevFillPageType | null>(null);
// 현재 locale 추출 (유효한 locale만 인식)
const VALID_LOCALES = ['ko', 'en'];
const firstSegment = pathname.split('/')[1];
const locale = VALID_LOCALES.includes(firstSegment) ? firstSegment : '';
// 비활성화 시 렌더링하지 않음
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 handleNavigate = (path: string) => {
if (path) {
router.push(locale ? `/${locale}${path}` : path);
}
};
// 플로우 데이터 표시
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;
const hasPath = !!step.path;
// 완료 버튼은 상세 페이지에서만 활성화 (이동 경로 없음)
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"
title="작업지시 상세 페이지에서 사용 가능"
>
<Icon className="w-4 h-4 mr-1" />
{step.label}
</Button>
</div>
);
}
// 활성화된 페이지: 폼 채우기
if (isActive) {
return (
<div key={step.type} className="flex items-center">
{index > 0 && <span className="text-yellow-400 mx-1"></span>}
<Button
size="sm"
variant="default"
disabled={!isRegistered || isCurrentLoading}
className="bg-yellow-500 hover:bg-yellow-600 text-white border-yellow-600"
onClick={() => handleFillForm(step.type)}
title="폼 자동 채우기"
>
{isCurrentLoading ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<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="outline"
disabled={!hasPath}
className="border-yellow-300 text-yellow-700 hover:bg-yellow-100 hover:border-yellow-500"
onClick={() => handleNavigate(step.path)}
title={hasPath ? `${step.label} 페이지로 이동` : '이동 경로 없음'}
>
<Icon className="w-4 h-4 mr-1" />
{step.label}
</Button>
</div>
);
})}
</div>
)}
{/* 회계 플로우 버튼 영역 */}
{isExpanded && (
<div className="flex items-center gap-2 px-3 pb-3 border-t border-yellow-300 pt-3">
<span className="text-xs text-yellow-600 font-medium mr-1">:</span>
{ACCOUNTING_STEPS.map((step) => {
const Icon = step.icon;
const isActive = activePage === step.type;
const isRegistered = hasRegisteredForm(step.type);
const isCurrentLoading = isLoading === step.type;
// 활성화된 페이지: 폼 채우기 (fillEnabled가 true인 경우만)
if (isActive && step.fillEnabled) {
return (
<Button
key={step.type}
size="sm"
variant="default"
disabled={!isRegistered || isCurrentLoading}
className="bg-blue-500 hover:bg-blue-600 text-white border-blue-600"
onClick={() => handleFillForm(step.type)}
title="폼 자동 채우기"
>
{isCurrentLoading ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Icon className="w-4 h-4 mr-1" />
)}
{step.label}
</Button>
);
}
// 비활성화된 페이지 또는 이동만 가능한 버튼: 해당 페이지로 이동
return (
<Button
key={step.type}
size="sm"
variant="outline"
className={`border-blue-300 text-blue-700 hover:bg-blue-100 hover:border-blue-500 ${isActive ? 'bg-blue-100 border-blue-500' : ''}`}
onClick={() => handleNavigate(step.path)}
title={`${step.label} 페이지로 이동`}
>
<Icon className="w-4 h-4 mr-1" />
{step.label}
</Button>
);
})}
</div>
)}
{/* 안내 메시지 */}
{isExpanded && !activePage && (
<div className="px-3 pb-3">
<p className="text-xs text-yellow-600">
//////
</p>
</div>
)}
</div>
</div>
);
}
export default DevToolbar;