Files
sam-react-prod/src/components/orders/OrderSalesDetailEdit.tsx
유병철 c2ed71540f feat(WEB): DatePicker 공통화 및 공정관리/작업자화면 대폭 개선
DatePicker 공통화:
- date-picker.tsx 공통 컴포넌트 신규 추가
- 전체 폼 컴포넌트 DatePicker 통일 적용 (50+ 파일)
- DateRangeSelector 개선

공정관리:
- RuleModal 대폭 리팩토링 (-592줄 → 간소화)
- ProcessForm, StepForm 개선
- ProcessDetail 수정, actions 확장

작업자화면:
- WorkerScreen 기능 대폭 확장 (+543줄)
- WorkItemCard 개선
- types 확장

회계/인사/영업/품질:
- BadDebtDetail, BillDetail, DepositDetail, SalesDetail 등 DatePicker 적용
- EmployeeForm, VacationDialog 등 DatePicker 적용
- OrderRegistration, QuoteRegistration DatePicker 적용
- InspectionCreate, InspectionDetail DatePicker 적용

공사관리/CEO대시보드:
- BiddingDetail, ContractDetail, HandoverReport 등 DatePicker 적용
- ScheduleDetailModal, TodayIssueSection 개선

기타:
- WorkOrderCreate/Edit/Detail/List 개선
- ShipmentCreate/Edit, ReceivingDetail 개선
- calendar, calendarEvents 수정
- datepicker 마이그레이션 체크리스트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:48:00 +09:00

735 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* 수주 수정 컴포넌트 (Edit Mode)
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
*
* - 기본 정보 (읽기전용)
* - 수주/배송 정보 (편집 가능)
* - 비고 (편집 가능)
* - 품목 내역 (생산 시작 후 수정 불가)
*/
import { useState, useEffect, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { DatePicker } from "@/components/ui/date-picker";
import { Textarea } from "@/components/ui/textarea";
import { PhoneInput } from "@/components/ui/phone-input";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AlertTriangle, ChevronDown, ChevronRight, ChevronsUpDown, Package } from "lucide-react";
import { toast } from "sonner";
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
import { orderSalesConfig } from "./orderSalesConfig";
import { BadgeSm } from "@/components/atoms/BadgeSm";
import { formatAmount } from "@/utils/formatAmount";
import {
OrderItem,
getOrderById,
updateOrder,
type OrderStatus,
} from "@/components/orders";
import { useCommonCodes } from "@/hooks/useCommonCodes";
// 수정 폼 데이터
interface EditFormData {
// 읽기전용 정보
lotNumber: string;
orderDate: string; // 접수일
quoteNumber: string;
client: string;
siteName: string;
manager: string;
contact: string;
status: OrderStatus;
// 수정 가능 정보
expectedShipDate: string;
expectedShipDateUndecided: boolean;
deliveryRequestDate: string;
deliveryMethod: string;
shippingCost: string;
receiver: string;
receiverContact: string;
address: string;
addressDetail: string;
remarks: string;
// 품목 (수정 제한)
items: OrderItem[];
canEditItems: boolean;
subtotal: number;
discountRate: number;
totalAmount: number;
// 제품 정보 (아코디언용)
products: Array<{
productName: string;
productCategory?: string;
openWidth?: string;
openHeight?: string;
quantity: number;
floor?: string;
code?: string;
}>;
}
// 옵션 타입 정의
interface SelectOption {
value: string;
label: string;
}
// 상태 뱃지 헬퍼
function getOrderStatusBadge(status: OrderStatus) {
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
order_registered: { label: "수주등록", className: "bg-gray-100 text-gray-700 border-gray-200" },
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
production_ordered: { label: "생산지시완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
produced: { label: "생산완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
shipping: { label: "출하중", className: "bg-purple-100 text-purple-700 border-purple-200" },
shipped: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
completed: { label: "완료", className: "bg-gray-500 text-white border-gray-500" },
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
};
const config = statusConfig[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
return (
<BadgeSm className={config.className}>
{config.label}
</BadgeSm>
);
}
interface OrderSalesDetailEditProps {
orderId: string;
}
export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
const router = useRouter();
const [form, setForm] = useState<EditFormData | null>(null);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
// 공통코드 옵션 (useCommonCodes 훅)
const { options: deliveryMethods } = useCommonCodes('delivery_method');
const { options: shippingCosts } = useCommonCodes('shipping_cost');
// 제품-부품 트리 토글
const toggleProduct = (key: string) => {
setExpandedProducts((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
// 모든 제품 확장
const expandAllProducts = () => {
if (form?.products) {
const allKeys = form.products.map((p) => `${p.floor || ""}-${p.code || ""}`);
allKeys.push("other-parts"); // 기타부품도 포함
setExpandedProducts(new Set(allKeys));
}
};
// 모든 제품 축소
const collapseAllProducts = () => {
setExpandedProducts(new Set());
};
// 제품별로 부품 그룹화 (floor_code, symbol_code 매칭)
const getItemsForProduct = (floor: string | undefined, code: string | undefined) => {
if (!form?.items) return [];
return form.items.filter((item) => {
const itemFloor = item.type || "";
const itemSymbol = item.symbol || "";
const productFloor = floor || "";
const productCode = code || "";
return itemFloor === productFloor && itemSymbol === productCode;
});
};
// 매칭되지 않은 부품 (orphan items)
const getUnmatchedItems = () => {
if (!form?.items || !form?.products) return form?.items || [];
const matchedIds = new Set<string>();
form.products.forEach((product) => {
const items = getItemsForProduct(product.floor, product.code);
items.forEach((item) => matchedIds.add(item.id));
});
return form.items.filter((item) => !matchedIds.has(item.id));
};
/**
* 수량 포맷 함수
* - EA, SET, PCS 등 개수 단위: 정수로 표시
* - M, M2, KG, L 등 측정 단위: 소수점 이하 불필요한 0 제거
*/
const formatQuantity = (quantity: number, unit?: string): string => {
const countableUnits = ["EA", "SET", "PCS", "개", "세트", "BOX", "ROLL"];
const upperUnit = (unit || "").toUpperCase();
if (countableUnits.includes(upperUnit)) {
return Math.round(quantity).toLocaleString();
}
const rounded = Math.round(quantity * 10000) / 10000;
return rounded.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 4
});
};
// 데이터 로드 (API)
useEffect(() => {
async function loadOrder() {
try {
setLoading(true);
const result = await getOrderById(orderId);
if (result.success && result.data) {
const order = result.data;
// 상태에 따라 품목 수정 가능 여부 결정
const canEditItems = !["in_production", "rework", "work_completed", "shipped"].includes(
order.status
);
// Order 데이터를 EditFormData로 변환
setForm({
lotNumber: order.lotNumber,
orderDate: order.orderDate || "",
quoteNumber: order.quoteNumber || "",
client: order.client,
siteName: order.siteName,
manager: order.manager || "",
contact: order.contact || "",
status: order.status,
expectedShipDate: order.expectedShipDate || "",
expectedShipDateUndecided: !order.expectedShipDate,
deliveryRequestDate: order.deliveryRequestDate || "",
deliveryMethod: order.deliveryMethod || "",
shippingCost: order.shippingCost || "",
receiver: order.receiver || "",
receiverContact: order.receiverContact || "",
address: order.address || "",
addressDetail: order.addressDetail || "",
remarks: order.remarks || "",
items: order.items || [],
canEditItems,
subtotal: order.subtotal || order.amount,
discountRate: order.discountRate || 0,
totalAmount: order.amount,
products: order.products || [],
});
} else {
toast.error(result.error || "수주 정보를 불러오는데 실패했습니다.");
router.push("/sales/order-management-sales");
}
} catch (error) {
console.error("Error loading order:", error);
toast.error("수주 정보를 불러오는 중 오류가 발생했습니다.");
router.push("/sales/order-management-sales");
} finally {
setLoading(false);
}
}
loadOrder();
}, [orderId, router]);
const handleCancel = () => {
// V2 패턴: ?mode=view로 이동
router.push(`/sales/order-management-sales/${orderId}?mode=view`);
};
// IntegratedDetailTemplate용 onSubmit 핸들러
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!form) return { success: false, error: "폼 데이터가 없습니다." };
// 유효성 검사
if (!form.deliveryRequestDate) {
return { success: false, error: "납품요청일을 입력해주세요." };
}
if (!form.receiver.trim()) {
return { success: false, error: "수신자를 입력해주세요." };
}
if (!form.receiverContact.trim()) {
return { success: false, error: "수신처를 입력해주세요." };
}
try {
// API 연동
const result = await updateOrder(orderId, {
clientId: undefined, // 기존 값 유지
siteName: form.siteName,
expectedShipDate: form.expectedShipDateUndecided ? undefined : form.expectedShipDate,
deliveryRequestDate: form.deliveryRequestDate,
deliveryMethod: form.deliveryMethod,
shippingCost: form.shippingCost,
receiver: form.receiver,
receiverContact: form.receiverContact,
address: form.address,
addressDetail: form.addressDetail,
remarks: form.remarks,
items: form.items.map((item) => ({
itemId: item.id ? parseInt(item.id, 10) : undefined,
itemCode: item.itemCode,
itemName: item.itemName,
specification: item.spec,
quantity: item.quantity,
unit: item.unit,
unitPrice: item.unitPrice,
})),
});
if (result.success) {
toast.success("수주가 수정되었습니다.");
// V2 패턴: 저장 후 view 모드로 이동
router.push(`/sales/order-management-sales/${orderId}?mode=view`);
return { success: true };
} else {
return { success: false, error: result.error || "수주 수정에 실패했습니다." };
}
} catch (error) {
console.error("Error updating order:", error);
return { success: false, error: "수주 수정 중 오류가 발생했습니다." };
}
}, [form, orderId, router]);
// 동적 config (수정 모드용 타이틀)
const dynamicConfig = useMemo(() => {
return {
...orderSalesConfig,
title: "수주",
actions: {
...orderSalesConfig.actions,
showEdit: false, // 수정 모드에서는 수정 버튼 숨김
showDelete: false,
},
};
}, []);
// 커스텀 헤더 액션 (상태 뱃지)
const customHeaderActions = useMemo(() => {
if (!form) return null;
return (
<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>
);
}, [form]);
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(() => {
if (!form) return null;
return (
<div className="space-y-6">
{/* 기본 정보 (읽기전용) */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<span className="text-sm font-normal text-muted-foreground">()</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.lotNumber}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.orderDate || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.client}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.siteName}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.manager || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.contact || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<div className="mt-1">{getOrderStatusBadge(form.status)}</div>
</div>
</div>
</CardContent>
</Card>
{/* 수주/배송 정보 (편집 가능) */}
<Card>
<CardHeader>
<CardTitle className="text-lg">/ </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 첫 번째 줄: 수주일, 납품요청일, 출고예정일, 배송방식 */}
<div className="space-y-2">
<Label className="text-muted-foreground"></Label>
<Input
value={form.orderDate || ""}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={form.deliveryRequestDate}
onChange={(date) =>
setForm({ ...form, deliveryRequestDate: date })
}
/>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker
value={form.expectedShipDate}
onChange={(date) =>
setForm({ ...form, expectedShipDate: date })
}
disabled={form.expectedShipDateUndecided}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
key={`deliveryMethod-${form.deliveryMethod}`}
value={form.deliveryMethod}
onValueChange={(value) =>
setForm({ ...form, deliveryMethod: value })
}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{deliveryMethods.map((method) => (
<SelectItem key={method.value} value={method.value}>
{method.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 두 번째 줄: 운임비용, 수신자, 수신처 */}
<div className="space-y-2">
<Label></Label>
<Select
key={`shippingCost-${form.shippingCost}`}
value={form.shippingCost}
onValueChange={(value) =>
setForm({ ...form, shippingCost: value })
}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{shippingCosts.map((cost) => (
<SelectItem key={cost.value} value={cost.value}>
{cost.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Input
value={form.receiver}
onChange={(e) =>
setForm({ ...form, receiver: e.target.value })
}
placeholder="수신자명 입력"
/>
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Input
value={form.receiverContact}
onChange={(e) =>
setForm({ ...form, receiverContact: e.target.value })
}
placeholder="수신처 입력"
/>
</div>
{/* 주소 - 전체 너비 */}
<div className="space-y-2 md:col-span-4">
<Label></Label>
<div className="flex gap-2">
<Input
value={form.address}
onChange={(e) =>
setForm({ ...form, address: e.target.value })
}
placeholder="주소"
className="flex-1"
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 비고 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={form.remarks}
onChange={(e) => setForm({ ...form, remarks: e.target.value })}
placeholder="특이사항을 입력하세요"
rows={4}
/>
</CardContent>
</Card>
{/* 제품내용 (아코디언) */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
{!form.canEditItems && (
<span className="flex items-center gap-1 text-sm font-normal text-orange-600">
<AlertTriangle className="h-4 w-4" />
</span>
)}
</CardTitle>
{form.products && form.products.length > 0 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={expandAllProducts}
className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
>
<ChevronsUpDown className="h-3 w-3" />
</button>
<button
type="button"
onClick={collapseAllProducts}
className="text-xs text-gray-500 hover:text-gray-700"
>
</button>
</div>
)}
</div>
</CardHeader>
<CardContent>
{form.products && form.products.length > 0 ? (
<div className="space-y-3">
{form.products.map((product, productIndex) => {
const productKey = `${product.floor || ""}-${product.code || ""}`;
const isExpanded = expandedProducts.has(productKey);
const productItems = getItemsForProduct(product.floor, product.code);
return (
<div
key={productIndex}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* 제품 헤더 (클릭하면 확장/축소) */}
<button
type="button"
onClick={() => toggleProduct(productKey)}
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<Package className="h-5 w-5 text-blue-600" />
<div>
<span className="font-medium">{product.productName}</span>
{product.openWidth && product.openHeight && (
<span className="ml-2 text-sm text-gray-500">
({product.openWidth} × {product.openHeight})
</span>
)}
</div>
</div>
<span className="text-sm text-gray-500">
{productItems.length}
</span>
</button>
{/* 부품 목록 (확장 시 표시) */}
{isExpanded && (
<div className="border-t">
{productItems.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{productItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.spec || "-"}</TableCell>
<TableCell className="text-center">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="p-4 text-center text-gray-400 text-sm">
</div>
)}
</div>
)}
</div>
);
})}
</div>
) : null}
</CardContent>
</Card>
{/* 기타부품 (아코디언) */}
{(() => {
const unmatchedItems = getUnmatchedItems();
if (unmatchedItems.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => toggleProduct("other-parts")}
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
>
<div className="flex items-center gap-3">
{expandedProducts.has("other-parts") ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<Package className="h-5 w-5 text-gray-400" />
<span className="font-medium text-gray-600"></span>
</div>
<span className="text-sm text-gray-500">
{unmatchedItems.length}
</span>
</button>
{expandedProducts.has("other-parts") && (
<div className="border-t">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{unmatchedItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.spec || "-"}</TableCell>
<TableCell className="text-center">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
);
})()}
</div>
);
}, [form]);
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode="edit"
initialData={form || {}}
itemId={orderId}
isLoading={loading}
headerActions={customHeaderActions}
onSubmit={handleSubmit}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}