diff --git a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx index 79e684e2..6209c0e9 100644 --- a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx @@ -124,6 +124,13 @@ export default function QuoteDetailPage() { router.push(`/sales/order-management-sales/new?quoteId=${quoteId}`); }, [router, quoteId]); + // 기존 수주 보기 핸들러 (이미 수주가 있는 경우) + const handleOrderView = useCallback(() => { + if (quote?.orderId) { + router.push(`/sales/order-management-sales/${quote.orderId}?mode=view`); + } + }, [router, quote?.orderId]); + // 수정 저장 핸들러 const handleSave = useCallback(async (data: QuoteFormDataV2, saveType: "temporary" | "final") => { setIsSaving(true); @@ -201,12 +208,13 @@ export default function QuoteDetailPage() { onSave={handleSave} onEdit={handleEdit} onOrderRegister={handleOrderRegister} + onOrderView={handleOrderView} initialData={quote as QuoteFormDataV2 | null} isLoading={isSaving} hideHeader={true} /> ); - }, [isEditMode, handleBack, handleSave, handleEdit, handleOrderRegister, quote, isSaving]); + }, [isEditMode, handleBack, handleSave, handleEdit, handleOrderRegister, handleOrderView, quote, isSaving]); // IntegratedDetailTemplate 사용 return ( diff --git a/src/components/atoms/BadgeSm.tsx b/src/components/atoms/BadgeSm.tsx index 1f6c304e..bdcdd264 100644 --- a/src/components/atoms/BadgeSm.tsx +++ b/src/components/atoms/BadgeSm.tsx @@ -12,8 +12,8 @@ import { cn } from "@/lib/utils"; * // 수주전환 상태 * 수주전환 * - * // 최종확정 상태 - * 최종확정 + * // 견적완료 상태 + * 견적완료 * * // 수정중 상태 * 2차 수정 @@ -24,7 +24,7 @@ import { cn } from "@/lib/utils"; export type BadgeSmVariant = | "converted" // 수주전환 - 보라색 - | "finalized" // 최종확정 - 초록색 + | "finalized" // 견적완료 - 초록색 | "revising" // 수정중 - 주황색 | "initial" // 최초작성 - 회색 | "current" // 현재버전 - 파란색 @@ -90,11 +90,11 @@ export function getQuoteStatusBadge(quote: { ); } - // 최종확정 + // 견적완료 if (quote.isFinal) { return ( - 최종확정 + 견적완료 ); } diff --git a/src/components/orders/OrderRegistration.tsx b/src/components/orders/OrderRegistration.tsx index 27d46ead..13a25e7d 100644 --- a/src/components/orders/OrderRegistration.tsx +++ b/src/components/orders/OrderRegistration.tsx @@ -368,6 +368,7 @@ export function OrderRegistration({ // 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가) const items: OrderItem[] = (quotation.items || []).map((qi: QuotationItem) => ({ id: qi.id, + itemId: qi.itemId ? Number(qi.itemId) : undefined, // Items Master 참조 ID itemCode: qi.itemCode, itemName: qi.itemName, type: qi.type, diff --git a/src/components/orders/actions.ts b/src/components/orders/actions.ts index 344bd890..890ae5f7 100644 --- a/src/components/orders/actions.ts +++ b/src/components/orders/actions.ts @@ -136,6 +136,7 @@ interface ApiQuoteForSelect { interface ApiQuoteItem { id: number; + item_id?: number | null; // Items Master 참조 ID item_code?: string; item_name: string; type_code?: string; @@ -431,6 +432,7 @@ export interface QuotationForSelect { export interface QuotationItem { id: string; + itemId?: string | null; // Items Master 참조 ID itemCode: string; itemName: string; type: string; // 종 @@ -705,6 +707,7 @@ function transformQuoteItemForSelect(apiItem: ApiQuoteItem): QuotationItem { return { id: String(apiItem.id), + itemId: apiItem.item_id ? String(apiItem.item_id) : null, itemCode: apiItem.item_code || '', itemName: apiItem.item_name, type: typeFromNote, diff --git a/src/components/process-management/ProcessForm.tsx b/src/components/process-management/ProcessForm.tsx index cafdcd72..d727824b 100644 --- a/src/components/process-management/ProcessForm.tsx +++ b/src/components/process-management/ProcessForm.tsx @@ -552,6 +552,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { onOpenChange={handleModalClose} onAdd={handleSaveRule} editRule={editingRule} + processId={initialData?.id} /> > ), diff --git a/src/components/process-management/RuleModal.tsx b/src/components/process-management/RuleModal.tsx index 55e91e3a..ce54882a 100644 --- a/src/components/process-management/RuleModal.tsx +++ b/src/components/process-management/RuleModal.tsx @@ -48,9 +48,11 @@ interface RuleModalProps { onOpenChange: (open: boolean) => void; onAdd: (rule: Omit) => void; editRule?: ClassificationRule; + /** 현재 공정 ID (다른 공정에 이미 배정된 품목 제외용) */ + processId?: string; } -export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProps) { +export function RuleModal({ open, onOpenChange, onAdd, editRule, processId }: RuleModalProps) { // 공통 상태 const [registrationType, setRegistrationType] = useState( editRule?.registrationType || 'pattern' @@ -85,10 +87,11 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp q: q || undefined, itemType: itemType === 'all' ? undefined : itemType, size: 1000, // 전체 품목 조회 + excludeProcessId: processId, // 다른 공정에 이미 배정된 품목 제외 }); setItemList(items); setIsItemsLoading(false); - }, []); + }, [processId]); // 검색어 유효성 검사 함수 const isValidSearchKeyword = (keyword: string): boolean => { diff --git a/src/components/process-management/StepDetail.tsx b/src/components/process-management/StepDetail.tsx index ed62e9a1..e6abdc0f 100644 --- a/src/components/process-management/StepDetail.tsx +++ b/src/components/process-management/StepDetail.tsx @@ -9,8 +9,9 @@ * - 완료 정보: 유형(선택 완료 시 완료/클릭 시 완료) */ +import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { ArrowLeft, Edit } from 'lucide-react'; +import { ArrowLeft, Edit, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -18,6 +19,18 @@ import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { useMenuStore } from '@/store/menuStore'; import { usePermission } from '@/hooks/usePermission'; +import { deleteProcessStep } from './actions'; +import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import type { ProcessStep } from '@/types/process'; interface StepDetailProps { @@ -28,7 +41,9 @@ interface StepDetailProps { export function StepDetail({ step, processId }: StepDetailProps) { const router = useRouter(); const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); - const { canUpdate } = usePermission(); + const { canUpdate, canDelete } = usePermission(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const handleEdit = () => { router.push( @@ -40,6 +55,24 @@ export function StepDetail({ step, processId }: StepDetailProps) { router.push(`/ko/master-data/process-management/${processId}`); }; + const handleDelete = async () => { + setIsDeleting(true); + try { + const result = await deleteProcessStep(processId, step.id); + if (result.success) { + toast.success('단계가 삭제되었습니다.'); + router.push(`/ko/master-data/process-management/${processId}`); + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + } catch { + toast.error('삭제 중 오류가 발생했습니다.'); + } finally { + setIsDeleting(false); + setShowDeleteDialog(false); + } + }; + return ( @@ -131,13 +164,48 @@ export function StepDetail({ step, processId }: StepDetailProps) { 공정으로 돌아가기 - {canUpdate && ( - - - 수정 - - )} + + {canDelete && ( + setShowDeleteDialog(true)} + size="sm" + className="md:size-default" + > + + 삭제 + + )} + {canUpdate && ( + + + 수정 + + )} + + + {/* 삭제 확인 다이얼로그 */} + + + + 단계 삭제 + + '{step.stepName}' 단계를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + + {isDeleting ? '삭제 중...' : '삭제'} + + + + ); } diff --git a/src/components/process-management/actions.ts b/src/components/process-management/actions.ts index 91d2b64d..1715318f 100644 --- a/src/components/process-management/actions.ts +++ b/src/components/process-management/actions.ts @@ -620,10 +620,13 @@ interface GetItemListParams { q?: string; itemType?: string; size?: number; + /** 해당 공정 외 다른 공정에 이미 배정된 품목 제외 (공정 ID) */ + excludeProcessId?: string; } /** * 품목 목록 조회 (분류 규칙용) + * - excludeProcessId: 다른 공정에 이미 배정된 품목 제외 (중복 방지) */ export async function getItemList(params?: GetItemListParams): Promise { try { @@ -631,6 +634,7 @@ export async function getItemList(params?: GetItemListParams): Promise void; /** 거래명세서 보기 */ @@ -34,6 +34,10 @@ interface QuoteFooterBarProps { onEdit?: () => void; /** 수주등록 */ onOrderRegister?: () => void; + /** 수주 보기 (이미 수주가 있는 경우) */ + onOrderView?: () => void; + /** 연결된 수주 ID (있으면 수주 보기, 없으면 수주등록) */ + orderId?: number | null; /** 할인하기 */ onDiscount?: () => void; /** 수식보기 */ @@ -61,6 +65,8 @@ export function QuoteFooterBar({ onBack, onEdit, onOrderRegister, + onOrderView, + orderId, onDiscount, onFormulaView, hasBomResult = false, @@ -132,8 +138,8 @@ export function QuoteFooterBar({ )} - {/* 수정 - view 모드에서만 표시 */} - {isViewMode && onEdit && ( + {/* 수정 - view 모드에서만 표시, 수주 등록된 경우 숨김 */} + {isViewMode && onEdit && !orderId && ( )} - {/* 할인하기 - view 모드에서는 비활성 */} + {/* 할인하기 - view 모드 또는 수주 등록된 경우 비활성 */} {onDiscount && ( )} - {/* 저장 - edit 모드에서만 표시 */} + {/* 저장 버튼 - edit 모드에서만 표시 */} + {/* final/converted 상태면 "저장", 그 외는 "임시저장" */} {!isViewMode && ( {isSaving ? ( ) : ( )} - 저장 + + {status === "final" || status === "converted" ? "저장" : "임시저장"} + )} - {/* 최종확정 - 양쪽 모드에서 표시 (final 상태가 아닐 때만) */} - {status !== "final" && ( + {/* 견적완료 - edit 모드에서만 표시 (final 상태가 아닐 때만) */} + {!isViewMode && status !== "final" && ( )} - 최종확정 + 견적완료 )} - {/* 수주등록 - final 상태일 때 표시 */} - {status === "final" && onOrderRegister && ( - - - 수주등록 - - )} + {/* 수주등록/수주보기 - view 모드에서 final 또는 converted 상태일 때 표시 */} + {isViewMode && (status === "final" || status === "converted") && (orderId ? ( + onOrderView && ( + + + 수주 보기 + + ) + ) : ( + onOrderRegister && ( + + + 수주등록 + + ) + ))} diff --git a/src/components/quotes/QuoteManagementClient.tsx b/src/components/quotes/QuoteManagementClient.tsx index ee5efb5f..35f3a040 100644 --- a/src/components/quotes/QuoteManagementClient.tsx +++ b/src/components/quotes/QuoteManagementClient.tsx @@ -382,7 +382,7 @@ export function QuoteManagementClient({ 전체 최초작성 N차수정 - 최종확정 + 견적완료 diff --git a/src/components/quotes/QuoteRegistrationV2.tsx b/src/components/quotes/QuoteRegistrationV2.tsx index 7cc5b506..833ec07c 100644 --- a/src/components/quotes/QuoteRegistrationV2.tsx +++ b/src/components/quotes/QuoteRegistrationV2.tsx @@ -95,10 +95,11 @@ export interface QuoteFormDataV2 { contact: string; // 연락처 vatType: "included" | "excluded"; // 부가세 (포함/별도) remarks: string; // 비고 - status: "draft" | "temporary" | "final"; // 작성중, 임시저장, 최종저장 + status: "draft" | "temporary" | "final" | "converted"; // 작성중, 임시저장, 최종저장, 수주전환 discountRate: number; // 할인율 (%) discountAmount: number; // 할인 금액 locations: LocationItem[]; + orderId?: number | null; // 연결된 수주 ID (수주전환 시 설정) } // ============================================================================= @@ -151,6 +152,8 @@ interface QuoteRegistrationV2Props { onCalculate?: () => void; onEdit?: () => void; onOrderRegister?: () => void; + /** 수주 보기 (이미 수주가 있는 경우) */ + onOrderView?: () => void; initialData?: QuoteFormDataV2 | null; isLoading?: boolean; /** IntegratedDetailTemplate 사용 시 타이틀 영역 숨김 */ @@ -168,6 +171,7 @@ export function QuoteRegistrationV2({ onCalculate, onEdit, onOrderRegister, + onOrderView, initialData, isLoading = false, hideHeader = false, @@ -844,7 +848,7 @@ export function QuoteRegistrationV2({ 상태 @@ -968,6 +972,8 @@ export function QuoteRegistrationV2({ onBack={onBack} onEdit={onEdit} onOrderRegister={onOrderRegister} + onOrderView={onOrderView} + orderId={formData.orderId} onDiscount={() => setDiscountModalOpen(true)} onFormulaView={() => setFormulaViewOpen(true)} hasBomResult={hasBomResult} diff --git a/src/components/quotes/types.ts b/src/components/quotes/types.ts index 548251b3..5ea304ce 100644 --- a/src/components/quotes/types.ts +++ b/src/components/quotes/types.ts @@ -22,7 +22,7 @@ export const QUOTE_STATUS_LABELS: Record = { sent: '발송완료', approved: '승인', rejected: '거절', - finalized: '최종확정', + finalized: '견적완료', converted: '수주전환', }; @@ -97,6 +97,7 @@ export interface Quote { updatedBy?: string; finalizedAt?: string; finalizedBy?: string; + orderId?: number | null; // 연결된 수주 ID (수주전환 시 설정) } // ===== API 응답 타입 ===== @@ -182,6 +183,8 @@ export interface QuoteApiData { updated_by: number | null; finalized_at: string | null; finalized_by: number | null; + // 연결된 수주 ID (수주전환 시 설정) + order_id?: number | null; // 관계 데이터 (with 로드 시) creator?: { id: number; @@ -269,6 +272,7 @@ export function transformApiToFrontend(apiData: QuoteApiData): Quote { updatedBy: apiData.updater?.name || undefined, finalizedAt: apiData.finalized_at || undefined, finalizedBy: apiData.finalizer?.name || undefined, + orderId: apiData.order_id ?? undefined, // 연결된 수주 ID }; } @@ -348,7 +352,7 @@ export const QUOTE_FILTER_OPTIONS: { value: QuoteFilterType; label: string }[] = { value: 'all', label: '전체' }, { value: 'initial', label: '최초작성' }, { value: 'revising', label: '수정중' }, - { value: 'final', label: '최종확정' }, + { value: 'final', label: '견적완료' }, { value: 'converted', label: '수주전환' }, ]; @@ -694,10 +698,11 @@ export interface QuoteFormDataV2 { contact: string; dueDate: string; remarks: string; - status: 'draft' | 'temporary' | 'final'; // 작성중, 임시저장, 최종저장 + status: 'draft' | 'temporary' | 'final' | 'converted'; // 작성중, 임시저장, 최종저장, 수주전환 discountRate: number; // 할인율 (%) discountAmount: number; // 할인 금액 locations: LocationItem[]; + orderId?: number | null; // 연결된 수주 ID (수주전환 시 설정) } // ============================================================================= @@ -709,7 +714,7 @@ export interface QuoteFormDataV2 { * * 핵심 차이점: * - V2는 locations[] 배열, API는 items[] + calculation_inputs.items[] 구조 - * - V2 status는 3가지 (draft/temporary/final), API status는 6가지 + * - V2 status는 4가지 (draft/temporary/final/converted), API status는 6가지 * - BOM 산출 결과가 있으면 items에 자재 상세 포함 */ export function transformV2ToApi( @@ -964,6 +969,7 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 { description?: string; discountRate?: number; discountAmount?: number; + orderId?: number | null; // 연결된 수주 ID (camelCase 버전) }; return { @@ -991,6 +997,8 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 { discountRate: Number(apiData.discount_rate) || transformed.discountRate || 0, discountAmount: Number(apiData.discount_amount) || transformed.discountAmount || 0, locations: locations, + // 연결된 수주 ID (raw API: order_id, transformed: orderId) + orderId: apiData.order_id ?? transformed.orderId ?? null, }; }