refactor: IntegratedDetailTemplate 마이그레이션 (수주/견적)

- order-management-sales/[id]/page.tsx: PageLayout → IntegratedDetailTemplate (view)
- order-management-sales/[id]/edit/page.tsx: PageLayout → IntegratedDetailTemplate (edit)
- EstimateDetailForm.tsx: PageLayout → IntegratedDetailTemplate (view/edit)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-20 16:47:33 +09:00
parent 29c257c9f8
commit 09b2c256fb
3 changed files with 205 additions and 198 deletions

View File

@@ -9,7 +9,7 @@
* - 품목 내역 (생산 시작 후 수정 불가)
*/
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { useRouter, useParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -31,11 +31,10 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { FileText, AlertTriangle } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { PageLayout } from "@/components/organisms/PageLayout";
import { PageHeader } from "@/components/organisms/PageHeader";
import { FormActions } from "@/components/organisms/FormActions";
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
import { orderSalesConfig } from "@/components/orders/orderSalesConfig";
import { BadgeSm } from "@/components/atoms/BadgeSm";
import { formatAmount } from "@/utils/formatAmount";
import {
@@ -119,7 +118,6 @@ export default function OrderEditPage() {
const [form, setForm] = useState<EditFormData | null>(null);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// 데이터 로드 (API)
useEffect(() => {
@@ -177,28 +175,25 @@ export default function OrderEditPage() {
router.push(`/sales/order-management-sales/${orderId}`);
};
const handleSave = async () => {
if (!form) return;
// onSubmit wrapper for IntegratedDetailTemplate
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!form) return { success: false, error: "폼 데이터가 없습니다." };
// 유효성 검사
if (!form.deliveryRequestDate) {
toast.error("납품요청일을 입력해주세요.");
return;
return { success: false, error: "납품요청일을 입력해주세요." };
}
if (!form.receiver.trim()) {
toast.error("수신(반장/업체)을 입력해주세요.");
return;
return { success: false, error: "수신(반장/업체)을 입력해주세요." };
}
if (!form.receiverContact.trim()) {
toast.error("수신처 연락처를 입력해주세요.");
return;
return { success: false, error: "수신처 연락처를 입력해주세요." };
}
setIsSaving(true);
try {
// API 연동
// 주의: clientId를 보내지 않으면 기존 값 유지됨
// clientName과 clientContact는 반드시 보내야 기존 값이 유지됨
const result = await updateOrder(orderId, {
clientName: form.client,
clientContact: form.contact,
@@ -226,44 +221,32 @@ export default function OrderEditPage() {
if (result.success) {
toast.success("수주가 수정되었습니다.");
router.push(`/sales/order-management-sales/${orderId}`);
return { success: true };
} else {
toast.error(result.error || "수주 수정에 실패했습니다.");
return { success: false, error: result.error };
}
} catch (error) {
console.error("Error updating order:", error);
toast.error("수주 수정 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
return { success: false, error: "수주 수정 중 오류가 발생했습니다." };
}
};
}, [form, orderId, router]);
// 폼 컨텐츠 렌더링
const renderFormContent = useCallback(() => {
if (!form) return null;
if (loading || !form) {
return (
<PageLayout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
</PageLayout>
);
}
return (
<PageLayout>
{/* 헤더 */}
<PageHeader
title="수주 수정"
icon={FileText}
actions={
<div className="flex items-center gap-2">
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
{form.lotNumber}
</code>
{getOrderStatusBadge(form.status)}
</div>
}
/>
<div className="space-y-6">
{/* 상태 뱃지 */}
<div className="flex items-center gap-2">
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
{form.lotNumber}
</code>
{getOrderStatusBadge(form.status)}
</div>
{/* 기본 정보 (읽기전용) */}
<Card>
<CardHeader>
@@ -545,18 +528,26 @@ export default function OrderEditPage() {
</CardContent>
</Card>
</div>
);
}, [form]);
{/* 하단 버튼 */}
<div className="sticky bottom-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t pt-4 pb-4 -mx-3 md:-mx-6 px-3 md:px-6 mt-6">
<FormActions
onSave={handleSave}
onCancel={handleCancel}
saveLabel="저장"
cancelLabel="취소"
saveLoading={isSaving}
saveDisabled={isSaving}
/>
</div>
</PageLayout>
// Edit mode config override
const editConfig = {
...orderSalesConfig,
title: '수주 수정',
};
return (
<IntegratedDetailTemplate
config={editConfig}
mode="edit"
initialData={form}
itemId={orderId}
isLoading={loading || !form}
onSubmit={handleSubmit}
onCancel={handleCancel}
onBack={handleCancel}
renderForm={renderFormContent}
/>
);
}

View File

@@ -9,7 +9,7 @@
* - 상태별 버튼 차이
*/
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { useRouter, useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -22,8 +22,6 @@ import {
TableRow,
} from "@/components/ui/table";
import {
FileText,
ArrowLeft,
Edit,
Factory,
XCircle,
@@ -39,8 +37,8 @@ import {
ChevronsUpDown,
} from "lucide-react";
import { toast } from "sonner";
import { PageLayout } from "@/components/organisms/PageLayout";
import { PageHeader } from "@/components/organisms/PageHeader";
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
import { orderSalesConfig } from "@/components/orders/orderSalesConfig";
import { BadgeSm } from "@/components/atoms/BadgeSm";
import { formatAmount } from "@/utils/formatAmount";
import {
@@ -339,105 +337,11 @@ export default function OrderDetailPage() {
return order.items.filter((item) => !matchedIds.has(item.id));
};
if (loading) {
// 상세 컨텐츠 렌더링
const renderViewContent = useCallback(() => {
if (!order) return null;
return (
<PageLayout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
</PageLayout>
);
}
if (!order) {
return (
<PageLayout>
<div className="text-center py-12">
<p className="text-muted-foreground"> .</p>
<Button variant="outline" onClick={handleBack} className="mt-4">
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
</div>
</PageLayout>
);
}
// 상태별 버튼 표시 여부
const showEditButton = order.status !== "shipped" && order.status !== "cancelled";
// 수주 확정 버튼: 수주등록 상태에서만 표시
const showConfirmButton = order.status === "order_registered";
// 생산지시 생성 버튼: 수주확정 상태에서만 표시
const showProductionCreateButton = order.status === "order_confirmed";
// 생산지시 보기 버튼: 생산지시완료 상태에서 숨김 (기획서 오류로 제거)
const showProductionViewButton = false;
// 생산지시 되돌리기 버튼: 생산지시완료 상태에서만 표시
const showRevertButton = order.status === "production_ordered";
// 수주확정 되돌리기 버튼: 수주확정 상태에서만 표시
const showRevertConfirmButton = order.status === "order_confirmed";
const showCancelButton =
order.status !== "shipped" &&
order.status !== "cancelled" &&
order.status !== "production_ordered";
return (
<PageLayout>
{/* 헤더 */}
<PageHeader
title="수주 상세"
icon={FileText}
actions={
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
{showEditButton && (
<Button variant="outline" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
)}
{showConfirmButton && (
<Button onClick={handleConfirmOrder} className="bg-green-600 hover:bg-green-700">
<CheckCircle2 className="h-4 w-4 mr-2" />
</Button>
)}
{showProductionCreateButton && (
<Button onClick={handleProductionOrder}>
<Factory className="h-4 w-4 mr-2" />
</Button>
)}
{showProductionViewButton && (
<Button onClick={handleViewProductionOrder}>
<Eye className="h-4 w-4 mr-2" />
</Button>
)}
{showRevertButton && (
<Button variant="outline" onClick={handleRevertProduction} className="border-amber-200 text-amber-600 hover:border-amber-300 hover:bg-amber-50">
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
)}
{showRevertConfirmButton && (
<Button variant="outline" onClick={handleRevertConfirmation} className="border-slate-300 text-slate-600 hover:border-slate-400 hover:bg-slate-50">
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
)}
{showCancelButton && (
<Button variant="outline" onClick={handleCancel} className="border-orange-200 text-orange-600 hover:border-orange-300">
<XCircle className="h-4 w-4 mr-2" />
</Button>
)}
</div>
}
/>
<div className="space-y-6">
{/* 수주 정보 헤더 */}
<Card>
@@ -790,6 +694,85 @@ export default function OrderDetailPage() {
</CardContent>
</Card>
</div>
);
}, [order, expandedProducts, expandAllProducts, collapseAllProducts, toggleProduct, getItemsForProduct, getUnmatchedItems, openDocumentModal]);
// 커스텀 헤더 액션 (상태별 버튼)
const renderHeaderActions = useCallback(() => {
if (!order) return null;
// 상태별 버튼 표시 여부
const showEditButton = order.status !== "shipped" && order.status !== "cancelled";
const showConfirmButton = order.status === "order_registered";
const showProductionCreateButton = order.status === "order_confirmed";
const showProductionViewButton = false;
const showRevertButton = order.status === "production_ordered";
const showRevertConfirmButton = order.status === "order_confirmed";
const showCancelButton =
order.status !== "shipped" &&
order.status !== "cancelled" &&
order.status !== "production_ordered";
return (
<div className="flex items-center gap-2 flex-wrap">
{showEditButton && (
<Button variant="outline" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
)}
{showConfirmButton && (
<Button onClick={handleConfirmOrder} className="bg-green-600 hover:bg-green-700">
<CheckCircle2 className="h-4 w-4 mr-2" />
</Button>
)}
{showProductionCreateButton && (
<Button onClick={handleProductionOrder}>
<Factory className="h-4 w-4 mr-2" />
</Button>
)}
{showProductionViewButton && (
<Button onClick={handleViewProductionOrder}>
<Eye className="h-4 w-4 mr-2" />
</Button>
)}
{showRevertButton && (
<Button variant="outline" onClick={handleRevertProduction} className="border-amber-200 text-amber-600 hover:border-amber-300 hover:bg-amber-50">
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
)}
{showRevertConfirmButton && (
<Button variant="outline" onClick={handleRevertConfirmation} className="border-slate-300 text-slate-600 hover:border-slate-400 hover:bg-slate-50">
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
)}
{showCancelButton && (
<Button variant="outline" onClick={handleCancel} className="border-orange-200 text-orange-600 hover:border-orange-300">
<XCircle className="h-4 w-4 mr-2" />
</Button>
)}
</div>
);
}, [order, handleEdit, handleConfirmOrder, handleProductionOrder, handleViewProductionOrder, handleRevertProduction, handleRevertConfirmation, handleCancel]);
return (
<>
<IntegratedDetailTemplate
config={orderSalesConfig}
mode="view"
initialData={order}
itemId={orderId}
isLoading={loading}
onBack={handleBack}
renderView={renderViewContent}
headerActions={renderHeaderActions()}
/>
{/* 문서 모달 */}
<OrderDocumentModal
@@ -1114,6 +1097,6 @@ export default function OrderDetailPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</PageLayout>
</>
);
}

View File

@@ -2,7 +2,7 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Loader2, List } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { getExpenseItemOptions, updateEstimate, type ExpenseItemOption } from './actions';
import { createBiddingFromEstimate } from '../bidding/actions';
import { useAuth } from '@/contexts/AuthContext';
@@ -17,8 +17,8 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { estimateConfig } from './estimateConfig';
import { toast } from 'sonner';
import type {
EstimateDetail,
@@ -106,9 +106,6 @@ export default function EstimateDetailForm({
controller: number;
} | null>(null);
// 조정단가 적용 여부 (전체 적용 버튼 클릭 시에만 true)
const useAdjustedPrice = appliedPrices !== null;
// ===== 네비게이션 핸들러 =====
const handleBack = useCallback(() => {
router.push('/ko/construction/project/bidding/estimates');
@@ -118,10 +115,6 @@ export default function EstimateDetailForm({
router.push(`/ko/construction/project/bidding/estimates/${estimateId}/edit`);
}, [router, estimateId]);
const handleCancel = useCallback(() => {
router.push(`/ko/construction/project/bidding/estimates/${estimateId}`);
}, [router, estimateId]);
// ===== 저장/삭제 핸들러 =====
const handleSave = useCallback(() => {
setShowSaveDialog(true);
@@ -622,17 +615,8 @@ export default function EstimateDetailForm({
[isViewMode]
);
// ===== 타이틀 및 설명 =====
const pageTitle = useMemo(() => {
return isEditMode ? '견적 수정' : '견적 상세';
}, [isEditMode]);
const pageDescription = useMemo(() => {
return isEditMode ? '견적 정보를 수정합니다' : '견적 정보를 등록하고 관리합니다';
}, [isEditMode]);
// ===== 헤더 버튼 =====
const headerActions = useMemo(() => {
const renderHeaderActions = useCallback(() => {
if (isViewMode) {
return (
<div className="flex gap-2">
@@ -645,18 +629,11 @@ export default function EstimateDetailForm({
<Button variant="outline" onClick={handleRegisterBidding} className="text-green-600 border-green-200 hover:bg-green-50">
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</div>
);
}
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
@@ -670,18 +647,11 @@ export default function EstimateDetailForm({
</Button>
</div>
);
}, [isViewMode, isLoading, handleBack, handleEdit, handleDelete, handleSave, handleRegisterBidding]);
return (
<PageLayout>
<PageHeader
title={pageTitle}
description={pageDescription}
icon={FileText}
actions={headerActions}
onBack={handleBack}
/>
}, [isViewMode, isLoading, handleDelete, handleSave, handleRegisterBidding]);
// ===== 컨텐츠 렌더링 =====
const renderContent = useCallback(() => {
return (
<div className="space-y-8">
{/* 견적 정보 + 현장설명회 + 입찰 정보 */}
<EstimateInfoSection
@@ -746,6 +716,69 @@ export default function EstimateDetailForm({
onReset={handleDetailReset}
/>
</div>
);
}, [
formData,
isViewMode,
isDragging,
documentInputRef,
expenseOptions,
appliedPrices,
handleBidInfoChange,
handleDocumentUpload,
handleDocumentRemove,
handleDragOver,
handleDragLeave,
handleDrop,
handleAddSummaryItem,
handleRemoveSummaryItem,
handleSummaryItemChange,
handleSummaryMemoChange,
handleAddExpenseItems,
handleRemoveSelectedExpenseItems,
handleExpenseItemChange,
handleExpenseSelectItem,
handleExpenseSelectAll,
handlePriceAdjustmentChange,
handlePriceAdjustmentSave,
handlePriceAdjustmentApplyAll,
handlePriceAdjustmentReset,
handleAddDetailItems,
handleRemoveDetailItem,
handleRemoveSelectedDetailItems,
handleDetailItemChange,
handleDetailSelectItem,
handleDetailSelectAll,
handleApplyAdjustedPriceToSelected,
handleDetailReset,
]);
// Edit 모드용 config (타이틀 변경)
const currentConfig = useMemo(() => {
if (isEditMode) {
return {
...estimateConfig,
title: '견적 수정',
description: '견적 정보를 수정합니다',
};
}
return estimateConfig;
}, [isEditMode]);
return (
<>
<IntegratedDetailTemplate
config={currentConfig}
mode={mode}
initialData={formData}
itemId={estimateId}
isLoading={false}
onBack={handleBack}
onEdit={handleEdit}
renderView={renderContent}
renderForm={renderContent}
headerActions={renderHeaderActions()}
/>
{/* 전자결재 모달 */}
<ElectronicApprovalModal
@@ -839,6 +872,6 @@ export default function EstimateDetailForm({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
</>
);
}