feat(WEB): 생산지시 공정관리 연동 및 견적번호 버그 수정
- 생산지시 페이지에 공정관리 API 연동 - getProcessList API로 사용중 공정 목록 로드 - 품목-공정 매칭 함수 추가 (classificationRules 기반) - 하드코딩된 DEFAULT_PROCESSES 제거, API 데이터로 대체 - workSteps 없을 시 안내 메시지 표시 - 수주 등록 시 quote_id 미전달 버그 수정 - transformFrontendToApi에 quote_id 변환 로직 추가 - 견적 선택 후 수주 등록 시 견적번호 정상 표시
This commit is contained in:
@@ -47,6 +47,8 @@ import {
|
||||
type Order,
|
||||
type CreateProductionOrderData,
|
||||
} from "@/components/orders/actions";
|
||||
import { getProcessList } from "@/components/process-management/actions";
|
||||
import type { Process } from "@/types/process";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
|
||||
// 수주 정보 타입
|
||||
@@ -56,7 +58,6 @@ interface OrderInfo {
|
||||
siteName: string;
|
||||
dueDate: string;
|
||||
itemCount: number;
|
||||
totalQuantity: string;
|
||||
creditGrade: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -92,20 +93,17 @@ interface MaterialRequirement {
|
||||
status: "sufficient" | "insufficient";
|
||||
}
|
||||
|
||||
// 스크린 품목 상세 타입
|
||||
// 스크린 품목 상세 타입 (order.items에서 변환)
|
||||
interface ScreenItemDetail {
|
||||
no: number;
|
||||
itemName: string;
|
||||
location: string;
|
||||
openWidth: number;
|
||||
openHeight: number;
|
||||
productWidth: number;
|
||||
productHeight: number;
|
||||
guideRail: string;
|
||||
shaft: string;
|
||||
capacity: string;
|
||||
finish: string;
|
||||
specification: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
supplyAmount: number;
|
||||
taxAmount: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// 가이드레일 BOM 타입
|
||||
@@ -208,137 +206,94 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
cancelled: "취소",
|
||||
};
|
||||
|
||||
// 샘플 작업지시 카드
|
||||
const SAMPLE_WORK_ORDER_CARDS: WorkOrderCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
type: "스크린",
|
||||
orderNumber: "KD-PL-251223-01",
|
||||
itemCount: 3,
|
||||
totalQuantity: "3EA",
|
||||
processes: ["1. 원단절단", "2. 미싱", "3. 앤드락작업", "4. 중간검사", "5. 포장"],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "절곡",
|
||||
orderNumber: "KD-PL-251223-02",
|
||||
itemCount: 3,
|
||||
totalQuantity: "3EA",
|
||||
processes: ["1. 절단", "2. 절곡", "3. 중간검사", "4. 포장"],
|
||||
},
|
||||
];
|
||||
// 품목과 공정 매칭 함수
|
||||
function matchItemToProcess(
|
||||
itemName: string,
|
||||
itemCode: string | undefined,
|
||||
processes: Process[]
|
||||
): Process | null {
|
||||
for (const process of processes) {
|
||||
for (const rule of process.classificationRules) {
|
||||
if (!rule.isActive) continue;
|
||||
|
||||
// 샘플 자재 소요량
|
||||
const SAMPLE_MATERIALS: MaterialRequirement[] = [
|
||||
{
|
||||
materialCode: "SCR-MAT-001",
|
||||
materialName: "스크린 원단",
|
||||
unit: "㎡",
|
||||
required: 45,
|
||||
currentStock: 500,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "SCR-MAT-002",
|
||||
materialName: "앤드락",
|
||||
unit: "EA",
|
||||
required: 6,
|
||||
currentStock: 800,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "BND-MAT-001",
|
||||
materialName: "철판",
|
||||
unit: "KG",
|
||||
required: 90,
|
||||
currentStock: 2000,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "BND-MAT-002",
|
||||
materialName: "가이드레일",
|
||||
unit: "M",
|
||||
required: 18,
|
||||
currentStock: 300,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "BND-MAT-003",
|
||||
materialName: "케이스",
|
||||
unit: "EA",
|
||||
required: 3,
|
||||
currentStock: 100,
|
||||
status: "sufficient",
|
||||
},
|
||||
];
|
||||
// 패턴 매칭 규칙
|
||||
if (rule.registrationType === "pattern") {
|
||||
let targetValue = "";
|
||||
if (rule.ruleType === "품목명") {
|
||||
targetValue = itemName;
|
||||
} else if (rule.ruleType === "품목코드" && itemCode) {
|
||||
targetValue = itemCode;
|
||||
}
|
||||
|
||||
// 샘플 스크린 품목 상세
|
||||
const SAMPLE_SCREEN_ITEMS: ScreenItemDetail[] = [
|
||||
{
|
||||
no: 1,
|
||||
itemName: "스크린 셔터 (프리미엄)",
|
||||
location: "로비 I-01",
|
||||
openWidth: 4500,
|
||||
openHeight: 3500,
|
||||
productWidth: 4640,
|
||||
productHeight: 3900,
|
||||
guideRail: "백면형 120-70",
|
||||
shaft: '4"',
|
||||
capacity: "160kg",
|
||||
finish: "SUS마감",
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
itemName: "스크린 셔터 (프리미엄)",
|
||||
location: "카페 I-02",
|
||||
openWidth: 4500,
|
||||
openHeight: 3500,
|
||||
productWidth: 4640,
|
||||
productHeight: 3900,
|
||||
guideRail: "백면형 120-70",
|
||||
shaft: '4"',
|
||||
capacity: "160kg",
|
||||
finish: "SUS마감",
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
itemName: "스크린 셔터 (프리미엄)",
|
||||
location: "헬스장 I-03",
|
||||
openWidth: 4500,
|
||||
openHeight: 3500,
|
||||
productWidth: 4640,
|
||||
productHeight: 3900,
|
||||
guideRail: "백면형 120-70",
|
||||
shaft: '4"',
|
||||
capacity: "160kg",
|
||||
finish: "SUS마감",
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
if (!targetValue) continue;
|
||||
|
||||
// 샘플 가이드레일 BOM
|
||||
const SAMPLE_GUIDE_RAIL_BOM: GuideRailBom[] = [
|
||||
{
|
||||
type: "백면형",
|
||||
spec: "120-70",
|
||||
code: "KSE01/KWE01",
|
||||
length: 3000,
|
||||
quantity: 6,
|
||||
},
|
||||
];
|
||||
let matched = false;
|
||||
switch (rule.matchingType) {
|
||||
case "startsWith":
|
||||
matched = targetValue.startsWith(rule.conditionValue);
|
||||
break;
|
||||
case "endsWith":
|
||||
matched = targetValue.endsWith(rule.conditionValue);
|
||||
break;
|
||||
case "contains":
|
||||
matched = targetValue.includes(rule.conditionValue);
|
||||
break;
|
||||
case "equals":
|
||||
matched = targetValue === rule.conditionValue;
|
||||
break;
|
||||
}
|
||||
|
||||
// 샘플 케이스(셔터박스) BOM
|
||||
const SAMPLE_CASE_BOM: CaseBom[] = [
|
||||
{ item: "케이스 본체", length: "L: 4000", quantity: 2 },
|
||||
{ item: "측면 덮개", length: "500-355", quantity: 6 },
|
||||
];
|
||||
if (matched) return process;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 샘플 하단 마감재 BOM
|
||||
const SAMPLE_BOTTOM_FINISH_BOM: BottomFinishBom[] = [
|
||||
{ item: "하단마감재", spec: "50-40", length: "L: 4000", quantity: 3 },
|
||||
];
|
||||
// 매칭되는 공정이 없으면 공정명으로 단순 매칭 시도
|
||||
// (예: 품목명에 "스크린"이 포함되면 "스크린" 공정 반환)
|
||||
for (const process of processes) {
|
||||
if (itemName.toLowerCase().includes(process.processName.toLowerCase())) {
|
||||
return process;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 수주 품목들에서 매칭되는 공정의 workSteps 추출
|
||||
function getWorkStepsForOrder(
|
||||
items: Array<{ itemName: string; itemCode?: string }>,
|
||||
processes: Process[]
|
||||
): string[] {
|
||||
// 첫 번째 품목으로 공정 매칭 시도
|
||||
if (items.length > 0 && processes.length > 0) {
|
||||
const firstItem = items[0];
|
||||
const matchedProcess = matchItemToProcess(
|
||||
firstItem.itemName,
|
||||
firstItem.itemCode,
|
||||
processes
|
||||
);
|
||||
|
||||
if (matchedProcess && matchedProcess.workSteps.length > 0) {
|
||||
// workSteps에 번호 추가하여 반환
|
||||
return matchedProcess.workSteps.map(
|
||||
(step, idx) => `${idx + 1}. ${step}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 매칭된 공정이 없거나 workSteps가 없으면 첫 번째 공정의 workSteps 사용
|
||||
if (processes.length > 0) {
|
||||
const firstProcess = processes[0];
|
||||
if (firstProcess.workSteps.length > 0) {
|
||||
return firstProcess.workSteps.map(
|
||||
(step, idx) => `${idx + 1}. ${step}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 공정이 없으면 빈 배열 반환
|
||||
return [];
|
||||
}
|
||||
|
||||
export default function ProductionOrderCreatePage() {
|
||||
const router = useRouter();
|
||||
@@ -348,6 +303,7 @@ export default function ProductionOrderCreatePage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [processes, setProcesses] = useState<Process[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 우선순위 상태
|
||||
@@ -359,16 +315,25 @@ export default function ProductionOrderCreatePage() {
|
||||
const [generatedOrderNumber, setGeneratedOrderNumber] = useState("");
|
||||
const [generatedWorkOrderId, setGeneratedWorkOrderId] = useState<string | null>(null);
|
||||
|
||||
// 수주 데이터 로드
|
||||
const fetchOrder = useCallback(async () => {
|
||||
// 수주 데이터 및 공정 목록 로드
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getOrderById(orderId);
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
// 수주 정보와 공정 목록을 병렬로 로드
|
||||
const [orderResult, processResult] = await Promise.all([
|
||||
getOrderById(orderId),
|
||||
getProcessList({ status: "사용중" }),
|
||||
]);
|
||||
|
||||
if (orderResult.success && orderResult.data) {
|
||||
setOrder(orderResult.data);
|
||||
} else {
|
||||
setError(result.error || "수주 정보 조회에 실패했습니다.");
|
||||
setError(orderResult.error || "수주 정보 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (processResult.success && processResult.data) {
|
||||
setProcesses(processResult.data.items);
|
||||
}
|
||||
} catch {
|
||||
setError("서버 오류가 발생했습니다.");
|
||||
@@ -378,8 +343,8 @@ export default function ProductionOrderCreatePage() {
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrder();
|
||||
}, [fetchOrder]);
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push(`/ko/sales/order-management-sales/${orderId}`);
|
||||
@@ -467,7 +432,20 @@ export default function ProductionOrderCreatePage() {
|
||||
}
|
||||
|
||||
const selectedConfig = getSelectedPriorityConfig();
|
||||
const workOrderCount = SAMPLE_WORK_ORDER_CARDS.length;
|
||||
const workOrderCount = 1; // 현재는 수주당 하나의 작업지시 생성
|
||||
|
||||
// order.items에서 스크린 품목 상세 데이터 변환
|
||||
const screenItems: ScreenItemDetail[] = (order.items || []).map((item, index) => ({
|
||||
no: item.serialNo || index + 1,
|
||||
itemName: item.itemName,
|
||||
specification: item.specification || "-",
|
||||
quantity: item.quantity,
|
||||
unit: item.unit || "EA",
|
||||
unitPrice: item.unitPrice,
|
||||
supplyAmount: item.supplyAmount,
|
||||
taxAmount: item.taxAmount,
|
||||
totalAmount: item.totalAmount,
|
||||
}));
|
||||
|
||||
// Order에서 UI에 표시할 데이터 변환
|
||||
const orderInfo = {
|
||||
@@ -476,7 +454,6 @@ export default function ProductionOrderCreatePage() {
|
||||
siteName: order.siteName,
|
||||
dueDate: order.expectedShipDate || "-",
|
||||
itemCount: order.itemCount,
|
||||
totalQuantity: `${order.itemCount}EA`,
|
||||
creditGrade: "B (관리)", // API에서 제공하지 않아 기본값 사용
|
||||
status: STATUS_LABELS[order.status] || order.status,
|
||||
amount: order.amount,
|
||||
@@ -537,10 +514,6 @@ export default function ProductionOrderCreatePage() {
|
||||
<p className="text-sm text-muted-foreground mb-1">품목 수</p>
|
||||
<p className="font-medium">{orderInfo.itemCount}건</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">총수량</p>
|
||||
<p className="font-medium">{orderInfo.totalQuantity}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">신용등급</p>
|
||||
<BadgeSm className="bg-green-100 text-green-700 border-green-200">
|
||||
@@ -662,98 +635,63 @@ export default function ProductionOrderCreatePage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{SAMPLE_WORK_ORDER_CARDS.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className={cn(
|
||||
"border rounded-lg p-4",
|
||||
card.type === "스크린" ? "bg-blue-50/50 border-blue-200" : "bg-orange-50/50 border-orange-200"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<BadgeSm className={cn(
|
||||
card.type === "스크린"
|
||||
? "bg-blue-100 text-blue-700 border-blue-200"
|
||||
: "bg-orange-100 text-orange-700 border-orange-200"
|
||||
)}>
|
||||
{card.type}
|
||||
</BadgeSm>
|
||||
<span className="font-mono text-sm font-medium">{card.orderNumber}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">품목 수</p>
|
||||
<p className="font-medium">{card.itemCount}건</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">총 수량</p>
|
||||
<p className="font-medium">{card.totalQuantity}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">공정 순서</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{card.processes.map((process, idx) => (
|
||||
{/* 수주 데이터 기반 작업지시 카드 */}
|
||||
<div className="border rounded-lg p-4 bg-blue-50/50 border-blue-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<BadgeSm className="bg-blue-100 text-blue-700 border-blue-200">
|
||||
스크린
|
||||
</BadgeSm>
|
||||
<span className="font-mono text-sm font-medium">{order.lotNumber}</span>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<p className="text-sm text-muted-foreground">품목 수</p>
|
||||
<p className="font-medium">{screenItems.length}건</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">공정 순서</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(() => {
|
||||
const workSteps = getWorkStepsForOrder(
|
||||
(order.items || []).map(item => ({
|
||||
itemName: item.itemName,
|
||||
itemCode: item.itemCode,
|
||||
})),
|
||||
processes
|
||||
);
|
||||
|
||||
if (workSteps.length === 0) {
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
공정관리에서 세부 작업단계를 등록해 주세요.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return workSteps.map((step, idx) => (
|
||||
<BadgeSm
|
||||
key={idx}
|
||||
className="bg-gray-50 text-gray-600 border-gray-200"
|
||||
>
|
||||
{process}
|
||||
{step}
|
||||
</BadgeSm>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자재 소요량 및 재고 현황 */}
|
||||
{/* 자재 소요량 및 재고 현황 - 추후 BOM API 연동 예정 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">자재 소요량 및 재고 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>자재코드</TableHead>
|
||||
<TableHead>자재명</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">소요량</TableHead>
|
||||
<TableHead className="text-right">현재고</TableHead>
|
||||
<TableHead className="text-center">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_MATERIALS.map((item) => (
|
||||
<TableRow key={item.materialCode}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.materialCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.materialName}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right font-medium">{item.required}</TableCell>
|
||||
<TableCell className="text-right">{item.currentStock.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<BadgeSm
|
||||
className={cn(
|
||||
item.status === "sufficient"
|
||||
? "bg-green-100 text-green-700 border-green-200"
|
||||
: "bg-red-100 text-red-700 border-red-200"
|
||||
)}
|
||||
>
|
||||
{item.status === "sufficient" ? "충분" : "부족"}
|
||||
</BadgeSm>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>BOM 데이터 연동 후 자재 소요량이 표시됩니다.</p>
|
||||
<p className="text-sm mt-1">(추후 제공 예정)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -761,7 +699,7 @@ export default function ProductionOrderCreatePage() {
|
||||
{/* 스크린 품목 상세 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">스크린 품목 상세 ({SAMPLE_SCREEN_ITEMS.length}건)</CardTitle>
|
||||
<CardTitle className="text-base">스크린 품목 상세 ({screenItems.length}건)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
@@ -770,37 +708,39 @@ export default function ProductionOrderCreatePage() {
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">No</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead>도면위치</TableHead>
|
||||
<TableHead className="text-right">개구폭</TableHead>
|
||||
<TableHead className="text-right">개구높이</TableHead>
|
||||
<TableHead className="text-right">제작폭</TableHead>
|
||||
<TableHead className="text-right">제작높이</TableHead>
|
||||
<TableHead>가이드레일</TableHead>
|
||||
<TableHead className="text-center">샤프트</TableHead>
|
||||
<TableHead className="text-center">용량</TableHead>
|
||||
<TableHead>마감</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">공급가</TableHead>
|
||||
<TableHead className="text-right">세액</TableHead>
|
||||
<TableHead className="text-right">합계</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_SCREEN_ITEMS.map((item) => (
|
||||
<TableRow key={item.no}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{String(item.no).padStart(2, "0")}
|
||||
{screenItems.length > 0 ? (
|
||||
screenItems.map((item) => (
|
||||
<TableRow key={item.no}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{String(item.no).padStart(2, "0")}
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.specification}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.unitPrice)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.supplyAmount)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.taxAmount)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatAmount(item.totalAmount)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
품목 정보가 없습니다.
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.location}</TableCell>
|
||||
<TableCell className="text-right">{item.openWidth.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{item.openHeight.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{item.productWidth.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{item.productHeight.toLocaleString()}</TableCell>
|
||||
<TableCell>{item.guideRail}</TableCell>
|
||||
<TableCell className="text-center">{item.shaft}</TableCell>
|
||||
<TableCell className="text-center">{item.capacity}</TableCell>
|
||||
<TableCell>{item.finish}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -832,91 +772,15 @@ export default function ProductionOrderCreatePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 절곡물 BOM */}
|
||||
{/* 절곡물 BOM - 추후 BOM API 연동 예정 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">절곡물 BOM</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 가이드레일 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">가이드레일</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>형태</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead className="text-right">길이</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_GUIDE_RAIL_BOM.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{item.type}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell>{item.code}</TableCell>
|
||||
<TableCell className="text-right">{item.length.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 케이스(셔터박스) */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">케이스(셔터박스) - 메인 규격: 500-330</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>품목</TableHead>
|
||||
<TableHead>길이</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_CASE_BOM.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{item.item}</TableCell>
|
||||
<TableCell>{item.length}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 마감재 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">하단 마감재</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>품목</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>길이</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_BOTTOM_FINISH_BOM.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{item.item}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell>{item.length}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<CardContent>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>BOM 데이터 연동 후 절곡물 정보가 표시됩니다.</p>
|
||||
<p className="text-sm mt-1">(가이드레일, 케이스, 하단 마감재 - 추후 제공 예정)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -943,7 +807,7 @@ export default function ProductionOrderCreatePage() {
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isSubmitting}>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
생산지시 확정 ({SAMPLE_SCREEN_ITEMS.length}건)
|
||||
생산지시 확정 ({screenItems.length}건)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,8 +89,7 @@ interface ApiQuoteForSelect {
|
||||
client?: {
|
||||
id: number;
|
||||
name: string;
|
||||
grade?: string;
|
||||
representative?: string;
|
||||
contact_person?: string; // 담당자
|
||||
phone?: string;
|
||||
} | null;
|
||||
items?: ApiQuoteItem[];
|
||||
@@ -103,12 +102,16 @@ interface ApiQuoteItem {
|
||||
type_code?: string;
|
||||
symbol?: string;
|
||||
specification?: string;
|
||||
quantity: number;
|
||||
// QuoteItem 모델 필드명 (calculated_quantity, total_price)
|
||||
calculated_quantity?: number;
|
||||
quantity?: number; // fallback
|
||||
unit?: string;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
total_price?: number;
|
||||
// 수주 품목에서 사용하는 필드명
|
||||
supply_amount?: number;
|
||||
tax_amount?: number;
|
||||
total_amount?: number;
|
||||
}
|
||||
|
||||
interface ApiWorkOrder {
|
||||
@@ -327,7 +330,8 @@ export interface QuotationForSelect {
|
||||
id: string;
|
||||
quoteNumber: string; // KD-PR-XXXXXX-XX
|
||||
grade: string; // A(우량), B(관리), C(주의)
|
||||
client: string; // 발주처
|
||||
clientId: string | null; // 발주처 ID
|
||||
client: string; // 발주처명
|
||||
siteName: string; // 현장명
|
||||
amount: number; // 총 금액
|
||||
itemCount: number; // 품목 수
|
||||
@@ -399,7 +403,7 @@ function transformApiToFrontend(apiData: ApiOrder): Order {
|
||||
memo: apiData.memo ?? undefined,
|
||||
remarks: apiData.remarks ?? undefined,
|
||||
note: apiData.note ?? undefined,
|
||||
items: apiData.items?.map(transformItemApiToFrontend), // 상세 페이지용 추가 필드 (API에서 매핑)
|
||||
items: apiData.items?.map(transformItemApiToFrontend) || [], // 상세 페이지용 추가 필드 (API에서 매핑)
|
||||
manager: apiData.client?.representative ?? undefined,
|
||||
contact: apiData.client_contact ?? apiData.client?.phone ?? undefined,
|
||||
deliveryRequestDate: apiData.delivery_date ?? undefined, // delivery_date를 공유
|
||||
@@ -435,33 +439,61 @@ function transformItemApiToFrontend(apiItem: ApiOrderItem): OrderItem {
|
||||
};
|
||||
}
|
||||
|
||||
function transformFrontendToApi(data: OrderFormData): Record<string, unknown> {
|
||||
function transformFrontendToApi(data: OrderFormData | Record<string, unknown>): Record<string, unknown> {
|
||||
// Handle both API OrderFormData and Registration form's OrderFormData
|
||||
const formData = data as Record<string, unknown>;
|
||||
|
||||
// Get client_id - handle both string (form) and number (api) types
|
||||
const clientId = formData.clientId;
|
||||
const clientIdValue = clientId ? (typeof clientId === 'string' ? parseInt(clientId, 10) || null : clientId) : null;
|
||||
|
||||
// Get items - handle both form's OrderItem[] and API's OrderItemFormData[]
|
||||
const items = (formData.items as Array<Record<string, unknown>>) || [];
|
||||
|
||||
// Get quote_id from selectedQuotation (견적에서 수주 생성 시)
|
||||
const selectedQuotation = formData.selectedQuotation as { id?: string } | undefined;
|
||||
const quoteIdValue = selectedQuotation?.id ? parseInt(selectedQuotation.id, 10) || null : null;
|
||||
|
||||
return {
|
||||
order_type_code: data.orderTypeCode || 'ORDER',
|
||||
category_code: data.categoryCode || null,
|
||||
client_id: data.clientId || null,
|
||||
client_name: data.clientName || null,
|
||||
client_contact: data.clientContact || null,
|
||||
site_name: data.siteName || null,
|
||||
supply_amount: data.supplyAmount || 0,
|
||||
tax_amount: data.taxAmount || 0,
|
||||
total_amount: data.totalAmount || 0,
|
||||
discount_rate: data.discountRate || 0,
|
||||
discount_amount: data.discountAmount || 0,
|
||||
delivery_date: data.deliveryDate || null,
|
||||
delivery_method_code: data.deliveryMethodCode || null,
|
||||
received_at: data.receivedAt || null,
|
||||
memo: data.memo || null,
|
||||
remarks: data.remarks || null,
|
||||
note: data.note || null,
|
||||
items: data.items?.map((item) => ({
|
||||
item_id: item.itemId || null,
|
||||
item_name: item.itemName,
|
||||
specification: item.specification || null,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit || null,
|
||||
unit_price: item.unitPrice,
|
||||
})) || [],
|
||||
quote_id: quoteIdValue,
|
||||
order_type_code: formData.orderTypeCode || 'ORDER',
|
||||
category_code: formData.categoryCode || null,
|
||||
client_id: clientIdValue,
|
||||
client_name: formData.clientName || null,
|
||||
client_contact: formData.clientContact || formData.contact || null,
|
||||
site_name: formData.siteName || null,
|
||||
supply_amount: formData.supplyAmount || formData.subtotal || 0,
|
||||
tax_amount: formData.taxAmount || 0,
|
||||
total_amount: formData.totalAmount || 0,
|
||||
discount_rate: formData.discountRate || 0,
|
||||
discount_amount: formData.discountAmount || 0,
|
||||
delivery_date: formData.deliveryDate || formData.deliveryRequestDate || null,
|
||||
delivery_method_code: formData.deliveryMethodCode || formData.deliveryMethod || null,
|
||||
received_at: formData.receivedAt || null,
|
||||
memo: formData.memo || null,
|
||||
remarks: formData.remarks || null,
|
||||
note: formData.note || null,
|
||||
items: items.map((item) => {
|
||||
// Handle both form's OrderItem (id, spec) and API's OrderItemFormData (itemId, specification)
|
||||
// 중요: 문자열로 전달될 수 있으므로 반드시 Number()로 변환
|
||||
const quantity = Number(item.quantity) || 0;
|
||||
const unitPrice = Number(item.unitPrice) || 0;
|
||||
const supplyAmount = quantity * unitPrice;
|
||||
const taxAmount = Math.round(supplyAmount * 0.1);
|
||||
|
||||
return {
|
||||
item_id: item.itemId || null,
|
||||
item_code: item.itemCode || null,
|
||||
item_name: item.itemName,
|
||||
specification: item.specification || item.spec || null,
|
||||
quantity,
|
||||
unit: item.unit || 'EA',
|
||||
unit_price: unitPrice,
|
||||
supply_amount: supplyAmount,
|
||||
tax_amount: taxAmount,
|
||||
total_amount: supplyAmount + taxAmount,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -490,6 +522,7 @@ function transformQuoteForSelect(apiData: ApiQuoteForSelect): QuotationForSelect
|
||||
id: String(apiData.id),
|
||||
quoteNumber: apiData.quote_number,
|
||||
grade: apiData.client?.grade || 'B', // 기본값 B(관리)
|
||||
clientId: apiData.client_id ? String(apiData.client_id) : null,
|
||||
client: apiData.client_name || apiData.client?.name || '',
|
||||
siteName: apiData.site_name || '',
|
||||
amount: apiData.total_amount,
|
||||
@@ -502,6 +535,14 @@ function transformQuoteForSelect(apiData: ApiQuoteForSelect): QuotationForSelect
|
||||
}
|
||||
|
||||
function transformQuoteItemForSelect(apiItem: ApiQuoteItem): QuotationItem {
|
||||
// QuoteItem 모델 필드명: calculated_quantity, total_price
|
||||
// 수주 품목 필드명: quantity, total_amount (fallback)
|
||||
// 중요: API에서 문자열로 반환될 수 있으므로 반드시 Number()로 변환
|
||||
const quantity = Number(apiItem.calculated_quantity ?? apiItem.quantity ?? 0);
|
||||
const unitPrice = Number(apiItem.unit_price ?? 0);
|
||||
// amount fallback: total_price → total_amount → 수량 * 단가 계산
|
||||
const amount = Number(apiItem.total_price ?? apiItem.total_amount ?? 0) || (quantity * unitPrice);
|
||||
|
||||
return {
|
||||
id: String(apiItem.id),
|
||||
itemCode: apiItem.item_code || '',
|
||||
@@ -509,10 +550,10 @@ function transformQuoteItemForSelect(apiItem: ApiQuoteItem): QuotationItem {
|
||||
type: apiItem.type_code || '',
|
||||
symbol: apiItem.symbol || '',
|
||||
spec: apiItem.specification || '',
|
||||
quantity: apiItem.quantity,
|
||||
quantity,
|
||||
unit: apiItem.unit || 'EA',
|
||||
unitPrice: apiItem.unit_price,
|
||||
amount: apiItem.total_amount,
|
||||
unitPrice,
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -971,8 +1012,10 @@ export async function getQuotesForSelect(params?: {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
// 확정(FINALIZED) 상태의 견적만 조회
|
||||
searchParams.set('status', 'FINALIZED');
|
||||
// 확정(finalized) 상태의 견적만 조회
|
||||
searchParams.set('status', 'finalized');
|
||||
// 품목 포함 (수주 전환용)
|
||||
searchParams.set('with_items', 'true');
|
||||
if (params?.q) searchParams.set('q', params.q);
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
if (params?.size) searchParams.set('size', String(params.size || 50));
|
||||
|
||||
Reference in New Issue
Block a user