Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
|
||||
];
|
||||
|
||||
const FORM_FIELDS = [
|
||||
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
|
||||
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
{ key: "division", label: "관리품목", type: "multi-category" },
|
||||
{ key: "type", label: "품목구분", type: "category" },
|
||||
@@ -209,6 +209,123 @@ export default function ItemInfoPage() {
|
||||
// 선택된 행
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
|
||||
const [manualInputValue, setManualInputValue] = useState<string>("");
|
||||
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
|
||||
const numberingRuleIdRef = useRef<string | null>(null);
|
||||
|
||||
// 프리뷰 코드에서 각 파트별 표시값을 추출
|
||||
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
|
||||
if (!previewCode || !rule?.parts) return [];
|
||||
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const globalSep = rule.separator || "";
|
||||
|
||||
// 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외)
|
||||
const partMeta = sorted.map((part: any, idx: number) => {
|
||||
const sep = idx < sorted.length - 1
|
||||
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
|
||||
: "";
|
||||
const config = part.autoConfig || {};
|
||||
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
|
||||
switch (part.partType) {
|
||||
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
|
||||
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
|
||||
case "date": {
|
||||
const now = new Date();
|
||||
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
|
||||
const fmt = config.dateFormat || "YYYYMMDD";
|
||||
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
|
||||
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
|
||||
}
|
||||
// sequence, category: 알 수 없으므로 프리뷰 코드에서 추출
|
||||
default: return { known: false, sep, isManual: false, partType: part.partType };
|
||||
}
|
||||
});
|
||||
|
||||
// 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출
|
||||
let remaining = previewCode;
|
||||
const results: { value: string; isManual: boolean; separator: string }[] = [];
|
||||
|
||||
for (let i = 0; i < partMeta.length; i++) {
|
||||
const meta = partMeta[i];
|
||||
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
|
||||
|
||||
if (meta.isManual) {
|
||||
// manual 파트: "____" 마커 찾아서 스킵
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) {
|
||||
remaining = remaining.substring(markerIdx + 4);
|
||||
// 이 파트 뒤 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: "", isManual: true, separator: meta.sep });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (meta.known) {
|
||||
// 알려진 값: 프리뷰 코드에서 해당 값을 소비
|
||||
const valIdx = remaining.indexOf(meta.value);
|
||||
if (valIdx >= 0) {
|
||||
remaining = remaining.substring(valIdx + meta.value.length);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: meta.value, isManual: false, separator: meta.sep });
|
||||
} else {
|
||||
// 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색
|
||||
let endIdx = remaining.length;
|
||||
|
||||
if (meta.sep) {
|
||||
// 이 파트 뒤 구분자로 끝나는 지점 찾기
|
||||
// 단, 다음 파트의 시작을 기준으로 역방향 탐색
|
||||
if (nextMeta) {
|
||||
// 다음 파트가 known이면 그 값이 시작되는 위치 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
// 구분자 + 다음 값 패턴으로 찾기
|
||||
const pattern = meta.sep + nextMeta.value;
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
// 다음이 manual이면 구분자 + "____" 패턴
|
||||
const pattern = meta.sep + "____";
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else {
|
||||
// 구분자로 분리
|
||||
const sepIdx = remaining.indexOf(meta.sep);
|
||||
if (sepIdx >= 0) endIdx = sepIdx;
|
||||
}
|
||||
}
|
||||
} else if (nextMeta) {
|
||||
// 구분자 없이 다음 파트의 알려진 값으로 경계 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
const valIdx = remaining.indexOf(nextMeta.value);
|
||||
if (valIdx >= 0) endIdx = valIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) endIdx = markerIdx;
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = remaining.substring(0, endIdx);
|
||||
remaining = remaining.substring(endIdx);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
results.push({ value: extracted, isManual: false, separator: meta.sep });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -288,33 +405,73 @@ export default function ItemInfoPage() {
|
||||
}, [fetchItems]);
|
||||
|
||||
// 채번 미리보기 로드
|
||||
const loadNumberingPreview = async () => {
|
||||
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
const rule = ruleRes.data?.data;
|
||||
if (rule?.ruleId) {
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
|
||||
return previewRes.data?.data?.generatedCode || "";
|
||||
setIsNumberingLoading(true);
|
||||
|
||||
// 규칙 조회 (캐싱)
|
||||
let rule = numberingRule;
|
||||
if (!rule) {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
rule = ruleRes.data?.data;
|
||||
if (rule) {
|
||||
setNumberingRule(rule);
|
||||
numberingRuleIdRef.current = rule.ruleId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
|
||||
// preview 호출 (formData + manualInputValue 전달)
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
|
||||
formData: currentFormData || {},
|
||||
manualInputValue: currentManualValue || undefined,
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
return "";
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
};
|
||||
|
||||
// 파트 값으로부터 전체 코드 조합 (수동 입력값 포함)
|
||||
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
|
||||
return parts.map((p, idx) => {
|
||||
const val = p.isManual ? manualVal : p.value;
|
||||
const sep = idx < parts.length - 1 ? p.separator : "";
|
||||
return val + sep;
|
||||
}).join("");
|
||||
};
|
||||
|
||||
// 등록 모달 열기
|
||||
const openRegisterModal = async () => {
|
||||
setFormData({});
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 채번 컬럼 자동 로드
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
// 채번 미리보기
|
||||
const result = await loadNumberingPreview({});
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const openEditModal = (item: any) => {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -325,13 +482,56 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const result = await loadNumberingPreview(rest);
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 변경 시 채번 preview 재호출
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
|
||||
const hasCategoryPart = numberingRule?.parts?.some(
|
||||
(p: any) => p.partType === "category" && p.generationMethod === "auto"
|
||||
);
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
|
||||
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [manualInputValue]);
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.item_name) {
|
||||
@@ -342,6 +542,7 @@ export default function ItemInfoPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && editId) {
|
||||
// 수정: item_number는 변경하지 않음
|
||||
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
|
||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||
originalData: { id: editId },
|
||||
@@ -349,8 +550,38 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
// 신규 등록: allocateCode 호출하여 실제 순번 확보
|
||||
let finalItemNumber = formData.item_number || "";
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual && manualInputValue
|
||||
? manualInputValue
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
|
||||
{ formData, userInputCode }
|
||||
);
|
||||
|
||||
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
|
||||
finalItemNumber = allocRes.data.data.generatedCode;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("채번 할당 실패:", err);
|
||||
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { id, created_date, updated_date, ...insertFields } = formData;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
...insertFields,
|
||||
item_number: finalItemNumber,
|
||||
});
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
@@ -484,7 +715,15 @@ export default function ItemInfoPage() {
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
|
||||
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||
@@ -534,6 +773,74 @@ export default function ItemInfoPage() {
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "numbering" ? (
|
||||
// 채번 세그먼트 UI
|
||||
isEditMode ? (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading && numberingParts.length === 0 ? (
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">생성 중...</span>
|
||||
</div>
|
||||
) : numberingParts.some(p => p.isManual) ? (
|
||||
// 파트별 세그먼트 렌더링 (수동 입력 파트 있음)
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{numberingParts.map((part, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === numberingParts.length - 1;
|
||||
if (part.isManual) {
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: buildCodeFromParts(numberingParts, val),
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
// auto 파트: 회색 배경 읽기전용
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<span className={cn(
|
||||
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
|
||||
isFirst && "rounded-l-[5px]",
|
||||
isLast && "rounded-r-[5px]",
|
||||
)}>
|
||||
{part.value}
|
||||
</span>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
placeholder="자동 채번"
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
)
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
@@ -548,8 +855,7 @@ export default function ItemInfoPage() {
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
|
||||
disabled={field.disabled && !isEditMode}
|
||||
placeholder={field.placeholder || field.label}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -412,21 +412,22 @@ export default function BomManagementPage() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// item_info의 division 카테고리
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/division/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results["division"] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
for (const itemCol of ["division", "unit"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results[itemCol] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setCategoryOptions(results);
|
||||
} catch {}
|
||||
@@ -1122,12 +1123,11 @@ export default function BomManagementPage() {
|
||||
}
|
||||
|
||||
// 현재 버전 ID 가져오기
|
||||
let versionId = currentVersionId;
|
||||
if (!versionId && bomId) {
|
||||
let versionId: string | null = null;
|
||||
if (bomId) {
|
||||
try {
|
||||
const verRes = await apiClient.get(`/bom/${bomId}/versions`);
|
||||
const verData = verRes.data?.data || verRes.data;
|
||||
versionId = verData?.currentVersionId || null;
|
||||
versionId = verRes.data?.currentVersionId || null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1193,7 +1193,20 @@ export default function BomManagementPage() {
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
}
|
||||
setItemSearchResults(rows);
|
||||
const resolved = rows.map((r: any) => {
|
||||
const out = { ...r };
|
||||
if (out.division) {
|
||||
out.division = out.division.split(",").map((c: string) => {
|
||||
const t = c.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === t)?.label || t;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
}
|
||||
if (out.unit) {
|
||||
out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
setItemSearchResults(resolved);
|
||||
} catch {
|
||||
toast.error("품목 검색에 실패했어요");
|
||||
} finally {
|
||||
@@ -1201,14 +1214,20 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUnit = (code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
const unitLabel = resolveUnit(item.unit);
|
||||
if (itemSearchTarget === "master") {
|
||||
setMasterForm((prev) => ({
|
||||
...prev,
|
||||
item_id: item.id,
|
||||
item_code: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
unit: item.unit || prev.unit,
|
||||
unit: unitLabel || prev.unit,
|
||||
}));
|
||||
setShowItemSearchModal(false);
|
||||
} else {
|
||||
@@ -1220,7 +1239,7 @@ export default function BomManagementPage() {
|
||||
item_number: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
quantity: "1",
|
||||
unit: item.unit || "",
|
||||
unit: unitLabel,
|
||||
process_type: "",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
|
||||
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (res.success) {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type RoutingVersion,
|
||||
} from "@/lib/api/processInfo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
function normalizeDefaultFlag(v: RoutingVersion): boolean {
|
||||
const raw = v.is_default as unknown;
|
||||
@@ -210,6 +211,7 @@ export function ItemRoutingTab() {
|
||||
return;
|
||||
}
|
||||
const list = res.data.map((v) => ({ ...v, is_default: normalizeDefaultFlag(v) }));
|
||||
list.sort((a, b) => (a.is_default === b.is_default ? 0 : a.is_default ? -1 : 1));
|
||||
setVersions(list);
|
||||
const preferred = preferVersionId ? list.find((v) => v.id === preferVersionId) : undefined;
|
||||
const def = list.find((v) => v.is_default);
|
||||
@@ -399,6 +401,38 @@ export function ItemRoutingTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// 선택 버전이 기본인지
|
||||
const selectedVersionIsDefault = useMemo(() => {
|
||||
if (!selectedVersionId) return false;
|
||||
const v = versions.find((v) => v.id === selectedVersionId);
|
||||
return v ? normalizeDefaultFlag(v) : false;
|
||||
}, [selectedVersionId, versions]);
|
||||
|
||||
// 기본 라우팅으로 설정
|
||||
const handleSetDefaultVersion = async () => {
|
||||
if (!selectedVersionId || !selectedItem) return;
|
||||
try {
|
||||
// 기존 기본 해제
|
||||
for (const v of versions) {
|
||||
if (normalizeDefaultFlag(v) && v.id !== selectedVersionId) {
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: v.id },
|
||||
updatedData: { is_default: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
// 선택 버전 기본 설정
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: selectedVersionId },
|
||||
updatedData: { is_default: true },
|
||||
});
|
||||
toast.success("기본 라우팅으로 설정했어요");
|
||||
await loadVersions(selectedItem, selectedVersionId);
|
||||
} catch {
|
||||
toast.error("기본 설정에 실패했어요");
|
||||
}
|
||||
};
|
||||
|
||||
const submitNewVersion = async () => {
|
||||
if (!selectedItem) return;
|
||||
const name = versionName.trim();
|
||||
@@ -639,6 +673,12 @@ export function ItemRoutingTab() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedVersionId && !selectedVersionIsDefault && (
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={handleSetDefaultVersion}>
|
||||
<Star className="h-3.5 w-3.5 fill-warning text-warning" />
|
||||
기본 설정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -249,7 +249,7 @@ export default function SalesOrderPage() {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
|
||||
} catch { /* skip */ }
|
||||
// 사용자 목록
|
||||
try {
|
||||
@@ -608,6 +608,24 @@ export default function SalesOrderPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
|
||||
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
const partnerId = masterForm.partner_id;
|
||||
let customerItemIds: Set<string> | null = null;
|
||||
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
@@ -615,6 +633,12 @@ export default function SalesOrderPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
let allRows = resData?.data || resData?.rows || [];
|
||||
|
||||
// 거래처우선일 때 연결된 품목만 표시
|
||||
if (customerItemIds) {
|
||||
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
|
||||
}
|
||||
|
||||
// 관리품목 필터 (코드/라벨 혼재 대응)
|
||||
if (itemSearchDivision !== "all") {
|
||||
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
|
||||
@@ -678,7 +702,8 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const _n = new Date();
|
||||
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
|
||||
for (const p of prices) {
|
||||
const start = p.start_date || "";
|
||||
const end = p.end_date || "";
|
||||
@@ -768,7 +793,8 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const _n = new Date();
|
||||
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
|
||||
const priceMap: Record<string, string> = {};
|
||||
for (const p of prices) {
|
||||
if (p.start_date && p.start_date > today) continue;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
|
||||
];
|
||||
|
||||
const FORM_FIELDS = [
|
||||
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
|
||||
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
{ key: "division", label: "관리품목", type: "multi-category" },
|
||||
{ key: "type", label: "품목구분", type: "category" },
|
||||
@@ -209,6 +209,123 @@ export default function ItemInfoPage() {
|
||||
// 선택된 행
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
|
||||
const [manualInputValue, setManualInputValue] = useState<string>("");
|
||||
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
|
||||
const numberingRuleIdRef = useRef<string | null>(null);
|
||||
|
||||
// 프리뷰 코드에서 각 파트별 표시값을 추출
|
||||
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
|
||||
if (!previewCode || !rule?.parts) return [];
|
||||
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const globalSep = rule.separator || "";
|
||||
|
||||
// 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외)
|
||||
const partMeta = sorted.map((part: any, idx: number) => {
|
||||
const sep = idx < sorted.length - 1
|
||||
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
|
||||
: "";
|
||||
const config = part.autoConfig || {};
|
||||
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
|
||||
switch (part.partType) {
|
||||
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
|
||||
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
|
||||
case "date": {
|
||||
const now = new Date();
|
||||
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
|
||||
const fmt = config.dateFormat || "YYYYMMDD";
|
||||
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
|
||||
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
|
||||
}
|
||||
// sequence, category: 알 수 없으므로 프리뷰 코드에서 추출
|
||||
default: return { known: false, sep, isManual: false, partType: part.partType };
|
||||
}
|
||||
});
|
||||
|
||||
// 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출
|
||||
let remaining = previewCode;
|
||||
const results: { value: string; isManual: boolean; separator: string }[] = [];
|
||||
|
||||
for (let i = 0; i < partMeta.length; i++) {
|
||||
const meta = partMeta[i];
|
||||
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
|
||||
|
||||
if (meta.isManual) {
|
||||
// manual 파트: "____" 마커 찾아서 스킵
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) {
|
||||
remaining = remaining.substring(markerIdx + 4);
|
||||
// 이 파트 뒤 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: "", isManual: true, separator: meta.sep });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (meta.known) {
|
||||
// 알려진 값: 프리뷰 코드에서 해당 값을 소비
|
||||
const valIdx = remaining.indexOf(meta.value);
|
||||
if (valIdx >= 0) {
|
||||
remaining = remaining.substring(valIdx + meta.value.length);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: meta.value, isManual: false, separator: meta.sep });
|
||||
} else {
|
||||
// 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색
|
||||
let endIdx = remaining.length;
|
||||
|
||||
if (meta.sep) {
|
||||
// 이 파트 뒤 구분자로 끝나는 지점 찾기
|
||||
// 단, 다음 파트의 시작을 기준으로 역방향 탐색
|
||||
if (nextMeta) {
|
||||
// 다음 파트가 known이면 그 값이 시작되는 위치 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
// 구분자 + 다음 값 패턴으로 찾기
|
||||
const pattern = meta.sep + nextMeta.value;
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
// 다음이 manual이면 구분자 + "____" 패턴
|
||||
const pattern = meta.sep + "____";
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else {
|
||||
// 구분자로 분리
|
||||
const sepIdx = remaining.indexOf(meta.sep);
|
||||
if (sepIdx >= 0) endIdx = sepIdx;
|
||||
}
|
||||
}
|
||||
} else if (nextMeta) {
|
||||
// 구분자 없이 다음 파트의 알려진 값으로 경계 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
const valIdx = remaining.indexOf(nextMeta.value);
|
||||
if (valIdx >= 0) endIdx = valIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) endIdx = markerIdx;
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = remaining.substring(0, endIdx);
|
||||
remaining = remaining.substring(endIdx);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
results.push({ value: extracted, isManual: false, separator: meta.sep });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -288,33 +405,73 @@ export default function ItemInfoPage() {
|
||||
}, [fetchItems]);
|
||||
|
||||
// 채번 미리보기 로드
|
||||
const loadNumberingPreview = async () => {
|
||||
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
const rule = ruleRes.data?.data;
|
||||
if (rule?.ruleId) {
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
|
||||
return previewRes.data?.data?.generatedCode || "";
|
||||
setIsNumberingLoading(true);
|
||||
|
||||
// 규칙 조회 (캐싱)
|
||||
let rule = numberingRule;
|
||||
if (!rule) {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
rule = ruleRes.data?.data;
|
||||
if (rule) {
|
||||
setNumberingRule(rule);
|
||||
numberingRuleIdRef.current = rule.ruleId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
|
||||
// preview 호출 (formData + manualInputValue 전달)
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
|
||||
formData: currentFormData || {},
|
||||
manualInputValue: currentManualValue || undefined,
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
return "";
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
};
|
||||
|
||||
// 파트 값으로부터 전체 코드 조합 (수동 입력값 포함)
|
||||
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
|
||||
return parts.map((p, idx) => {
|
||||
const val = p.isManual ? manualVal : p.value;
|
||||
const sep = idx < parts.length - 1 ? p.separator : "";
|
||||
return val + sep;
|
||||
}).join("");
|
||||
};
|
||||
|
||||
// 등록 모달 열기
|
||||
const openRegisterModal = async () => {
|
||||
setFormData({});
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 채번 컬럼 자동 로드
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
// 채번 미리보기
|
||||
const result = await loadNumberingPreview({});
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const openEditModal = (item: any) => {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -325,13 +482,56 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const result = await loadNumberingPreview(rest);
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 변경 시 채번 preview 재호출
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
|
||||
const hasCategoryPart = numberingRule?.parts?.some(
|
||||
(p: any) => p.partType === "category" && p.generationMethod === "auto"
|
||||
);
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
|
||||
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [manualInputValue]);
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.item_name) {
|
||||
@@ -342,6 +542,7 @@ export default function ItemInfoPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && editId) {
|
||||
// 수정: item_number는 변경하지 않음
|
||||
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
|
||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||
originalData: { id: editId },
|
||||
@@ -349,8 +550,38 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
// 신규 등록: allocateCode 호출하여 실제 순번 확보
|
||||
let finalItemNumber = formData.item_number || "";
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual && manualInputValue
|
||||
? manualInputValue
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
|
||||
{ formData, userInputCode }
|
||||
);
|
||||
|
||||
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
|
||||
finalItemNumber = allocRes.data.data.generatedCode;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("채번 할당 실패:", err);
|
||||
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { id, created_date, updated_date, ...insertFields } = formData;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
...insertFields,
|
||||
item_number: finalItemNumber,
|
||||
});
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
@@ -484,7 +715,15 @@ export default function ItemInfoPage() {
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
|
||||
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||
@@ -534,6 +773,74 @@ export default function ItemInfoPage() {
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "numbering" ? (
|
||||
// 채번 세그먼트 UI
|
||||
isEditMode ? (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading && numberingParts.length === 0 ? (
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">생성 중...</span>
|
||||
</div>
|
||||
) : numberingParts.some(p => p.isManual) ? (
|
||||
// 파트별 세그먼트 렌더링 (수동 입력 파트 있음)
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{numberingParts.map((part, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === numberingParts.length - 1;
|
||||
if (part.isManual) {
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: buildCodeFromParts(numberingParts, val),
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
// auto 파트: 회색 배경 읽기전용
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<span className={cn(
|
||||
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
|
||||
isFirst && "rounded-l-[5px]",
|
||||
isLast && "rounded-r-[5px]",
|
||||
)}>
|
||||
{part.value}
|
||||
</span>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
placeholder="자동 채번"
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
)
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
@@ -548,8 +855,7 @@ export default function ItemInfoPage() {
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
|
||||
disabled={field.disabled && !isEditMode}
|
||||
placeholder={field.placeholder || field.label}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -412,21 +412,23 @@ export default function BomManagementPage() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// item_info의 division 카테고리
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/division/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results["division"] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
// item_info의 division, unit 카테고리
|
||||
for (const itemCol of ["division", "unit"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results[itemCol] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setCategoryOptions(results);
|
||||
} catch {}
|
||||
@@ -1121,13 +1123,12 @@ export default function BomManagementPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 버전 ID 가져오기
|
||||
let versionId = currentVersionId;
|
||||
if (!versionId && bomId) {
|
||||
// 현재 버전 ID 가져오기 (initialize-version 후 최신 값)
|
||||
let versionId: string | null = null;
|
||||
if (bomId) {
|
||||
try {
|
||||
const verRes = await apiClient.get(`/bom/${bomId}/versions`);
|
||||
const verData = verRes.data?.data || verRes.data;
|
||||
versionId = verData?.currentVersionId || null;
|
||||
versionId = verRes.data?.currentVersionId || null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1193,7 +1194,21 @@ export default function BomManagementPage() {
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
}
|
||||
setItemSearchResults(rows);
|
||||
// 카테고리 코드 → 라벨 변환 (division + unit)
|
||||
const resolved = rows.map((r: any) => {
|
||||
const out = { ...r };
|
||||
if (out.division) {
|
||||
out.division = out.division.split(",").map((c: string) => {
|
||||
const t = c.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === t)?.label || t;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
}
|
||||
if (out.unit) {
|
||||
out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
setItemSearchResults(resolved);
|
||||
} catch {
|
||||
toast.error("품목 검색에 실패했어요");
|
||||
} finally {
|
||||
@@ -1201,14 +1216,20 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUnit = (code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
const unitLabel = resolveUnit(item.unit);
|
||||
if (itemSearchTarget === "master") {
|
||||
setMasterForm((prev) => ({
|
||||
...prev,
|
||||
item_id: item.id,
|
||||
item_code: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
unit: item.unit || prev.unit,
|
||||
unit: unitLabel || prev.unit,
|
||||
}));
|
||||
setShowItemSearchModal(false);
|
||||
} else {
|
||||
@@ -1220,7 +1241,7 @@ export default function BomManagementPage() {
|
||||
item_number: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
quantity: "1",
|
||||
unit: item.unit || "",
|
||||
unit: unitLabel,
|
||||
process_type: "",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type RoutingVersion,
|
||||
} from "@/lib/api/processInfo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
function normalizeDefaultFlag(v: RoutingVersion): boolean {
|
||||
const raw = v.is_default as unknown;
|
||||
@@ -210,6 +211,7 @@ export function ItemRoutingTab() {
|
||||
return;
|
||||
}
|
||||
const list = res.data.map((v) => ({ ...v, is_default: normalizeDefaultFlag(v) }));
|
||||
list.sort((a, b) => (a.is_default === b.is_default ? 0 : a.is_default ? -1 : 1));
|
||||
setVersions(list);
|
||||
const preferred = preferVersionId ? list.find((v) => v.id === preferVersionId) : undefined;
|
||||
const def = list.find((v) => v.is_default);
|
||||
@@ -399,6 +401,38 @@ export function ItemRoutingTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// 선택 버전이 기본인지
|
||||
const selectedVersionIsDefault = useMemo(() => {
|
||||
if (!selectedVersionId) return false;
|
||||
const v = versions.find((v) => v.id === selectedVersionId);
|
||||
return v ? normalizeDefaultFlag(v) : false;
|
||||
}, [selectedVersionId, versions]);
|
||||
|
||||
// 기본 라우팅으로 설정
|
||||
const handleSetDefaultVersion = async () => {
|
||||
if (!selectedVersionId || !selectedItem) return;
|
||||
try {
|
||||
// 기존 기본 해제
|
||||
for (const v of versions) {
|
||||
if (normalizeDefaultFlag(v) && v.id !== selectedVersionId) {
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: v.id },
|
||||
updatedData: { is_default: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
// 선택 버전 기본 설정
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: selectedVersionId },
|
||||
updatedData: { is_default: true },
|
||||
});
|
||||
toast.success("기본 라우팅으로 설정했어요");
|
||||
await loadVersions(selectedItem, selectedVersionId);
|
||||
} catch {
|
||||
toast.error("기본 설정에 실패했어요");
|
||||
}
|
||||
};
|
||||
|
||||
const submitNewVersion = async () => {
|
||||
if (!selectedItem) return;
|
||||
const name = versionName.trim();
|
||||
@@ -639,6 +673,12 @@ export function ItemRoutingTab() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedVersionId && !selectedVersionIsDefault && (
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={handleSetDefaultVersion}>
|
||||
<Star className="h-3.5 w-3.5 fill-warning text-warning" />
|
||||
기본 설정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -249,7 +249,7 @@ export default function SalesOrderPage() {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
|
||||
} catch { /* skip */ }
|
||||
// 사용자 목록
|
||||
try {
|
||||
@@ -608,6 +608,24 @@ export default function SalesOrderPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
|
||||
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
const partnerId = masterForm.partner_id;
|
||||
let customerItemIds: Set<string> | null = null;
|
||||
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
@@ -615,6 +633,12 @@ export default function SalesOrderPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
let allRows = resData?.data || resData?.rows || [];
|
||||
|
||||
// 거래처우선일 때 연결된 품목만 표시
|
||||
if (customerItemIds) {
|
||||
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
|
||||
}
|
||||
|
||||
// 관리품목 필터 (코드/라벨 혼재 대응)
|
||||
if (itemSearchDivision !== "all") {
|
||||
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
|
||||
@@ -678,7 +702,8 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const _n = new Date();
|
||||
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
|
||||
for (const p of prices) {
|
||||
const start = p.start_date || "";
|
||||
const end = p.end_date || "";
|
||||
@@ -768,7 +793,8 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const _n = new Date();
|
||||
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
|
||||
const priceMap: Record<string, string> = {};
|
||||
for (const p of prices) {
|
||||
if (p.start_date && p.start_date > today) continue;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
|
||||
];
|
||||
|
||||
const FORM_FIELDS = [
|
||||
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
|
||||
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
{ key: "division", label: "관리품목", type: "multi-category" },
|
||||
{ key: "type", label: "품목구분", type: "category" },
|
||||
@@ -209,6 +209,123 @@ export default function ItemInfoPage() {
|
||||
// 선택된 행
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
|
||||
const [manualInputValue, setManualInputValue] = useState<string>("");
|
||||
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
|
||||
const numberingRuleIdRef = useRef<string | null>(null);
|
||||
|
||||
// 프리뷰 코드에서 각 파트별 표시값을 추출
|
||||
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
|
||||
if (!previewCode || !rule?.parts) return [];
|
||||
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const globalSep = rule.separator || "";
|
||||
|
||||
// 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외)
|
||||
const partMeta = sorted.map((part: any, idx: number) => {
|
||||
const sep = idx < sorted.length - 1
|
||||
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
|
||||
: "";
|
||||
const config = part.autoConfig || {};
|
||||
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
|
||||
switch (part.partType) {
|
||||
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
|
||||
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
|
||||
case "date": {
|
||||
const now = new Date();
|
||||
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
|
||||
const fmt = config.dateFormat || "YYYYMMDD";
|
||||
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
|
||||
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
|
||||
}
|
||||
// sequence, category: 알 수 없으므로 프리뷰 코드에서 추출
|
||||
default: return { known: false, sep, isManual: false, partType: part.partType };
|
||||
}
|
||||
});
|
||||
|
||||
// 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출
|
||||
let remaining = previewCode;
|
||||
const results: { value: string; isManual: boolean; separator: string }[] = [];
|
||||
|
||||
for (let i = 0; i < partMeta.length; i++) {
|
||||
const meta = partMeta[i];
|
||||
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
|
||||
|
||||
if (meta.isManual) {
|
||||
// manual 파트: "____" 마커 찾아서 스킵
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) {
|
||||
remaining = remaining.substring(markerIdx + 4);
|
||||
// 이 파트 뒤 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: "", isManual: true, separator: meta.sep });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (meta.known) {
|
||||
// 알려진 값: 프리뷰 코드에서 해당 값을 소비
|
||||
const valIdx = remaining.indexOf(meta.value);
|
||||
if (valIdx >= 0) {
|
||||
remaining = remaining.substring(valIdx + meta.value.length);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: meta.value, isManual: false, separator: meta.sep });
|
||||
} else {
|
||||
// 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색
|
||||
let endIdx = remaining.length;
|
||||
|
||||
if (meta.sep) {
|
||||
// 이 파트 뒤 구분자로 끝나는 지점 찾기
|
||||
// 단, 다음 파트의 시작을 기준으로 역방향 탐색
|
||||
if (nextMeta) {
|
||||
// 다음 파트가 known이면 그 값이 시작되는 위치 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
// 구분자 + 다음 값 패턴으로 찾기
|
||||
const pattern = meta.sep + nextMeta.value;
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
// 다음이 manual이면 구분자 + "____" 패턴
|
||||
const pattern = meta.sep + "____";
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else {
|
||||
// 구분자로 분리
|
||||
const sepIdx = remaining.indexOf(meta.sep);
|
||||
if (sepIdx >= 0) endIdx = sepIdx;
|
||||
}
|
||||
}
|
||||
} else if (nextMeta) {
|
||||
// 구분자 없이 다음 파트의 알려진 값으로 경계 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
const valIdx = remaining.indexOf(nextMeta.value);
|
||||
if (valIdx >= 0) endIdx = valIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) endIdx = markerIdx;
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = remaining.substring(0, endIdx);
|
||||
remaining = remaining.substring(endIdx);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
results.push({ value: extracted, isManual: false, separator: meta.sep });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -288,33 +405,73 @@ export default function ItemInfoPage() {
|
||||
}, [fetchItems]);
|
||||
|
||||
// 채번 미리보기 로드
|
||||
const loadNumberingPreview = async () => {
|
||||
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
const rule = ruleRes.data?.data;
|
||||
if (rule?.ruleId) {
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
|
||||
return previewRes.data?.data?.generatedCode || "";
|
||||
setIsNumberingLoading(true);
|
||||
|
||||
// 규칙 조회 (캐싱)
|
||||
let rule = numberingRule;
|
||||
if (!rule) {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
rule = ruleRes.data?.data;
|
||||
if (rule) {
|
||||
setNumberingRule(rule);
|
||||
numberingRuleIdRef.current = rule.ruleId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
|
||||
// preview 호출 (formData + manualInputValue 전달)
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
|
||||
formData: currentFormData || {},
|
||||
manualInputValue: currentManualValue || undefined,
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
return "";
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
};
|
||||
|
||||
// 파트 값으로부터 전체 코드 조합 (수동 입력값 포함)
|
||||
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
|
||||
return parts.map((p, idx) => {
|
||||
const val = p.isManual ? manualVal : p.value;
|
||||
const sep = idx < parts.length - 1 ? p.separator : "";
|
||||
return val + sep;
|
||||
}).join("");
|
||||
};
|
||||
|
||||
// 등록 모달 열기
|
||||
const openRegisterModal = async () => {
|
||||
setFormData({});
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 채번 컬럼 자동 로드
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
// 채번 미리보기
|
||||
const result = await loadNumberingPreview({});
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const openEditModal = (item: any) => {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -325,13 +482,56 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const result = await loadNumberingPreview(rest);
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 변경 시 채번 preview 재호출
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
|
||||
const hasCategoryPart = numberingRule?.parts?.some(
|
||||
(p: any) => p.partType === "category" && p.generationMethod === "auto"
|
||||
);
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
|
||||
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [manualInputValue]);
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.item_name) {
|
||||
@@ -342,6 +542,7 @@ export default function ItemInfoPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && editId) {
|
||||
// 수정: item_number는 변경하지 않음
|
||||
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
|
||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||
originalData: { id: editId },
|
||||
@@ -349,8 +550,38 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
// 신규 등록: allocateCode 호출하여 실제 순번 확보
|
||||
let finalItemNumber = formData.item_number || "";
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual && manualInputValue
|
||||
? manualInputValue
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
|
||||
{ formData, userInputCode }
|
||||
);
|
||||
|
||||
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
|
||||
finalItemNumber = allocRes.data.data.generatedCode;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("채번 할당 실패:", err);
|
||||
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { id, created_date, updated_date, ...insertFields } = formData;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
...insertFields,
|
||||
item_number: finalItemNumber,
|
||||
});
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
@@ -484,7 +715,15 @@ export default function ItemInfoPage() {
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
|
||||
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||
@@ -534,6 +773,74 @@ export default function ItemInfoPage() {
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "numbering" ? (
|
||||
// 채번 세그먼트 UI
|
||||
isEditMode ? (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading && numberingParts.length === 0 ? (
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">생성 중...</span>
|
||||
</div>
|
||||
) : numberingParts.some(p => p.isManual) ? (
|
||||
// 파트별 세그먼트 렌더링 (수동 입력 파트 있음)
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{numberingParts.map((part, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === numberingParts.length - 1;
|
||||
if (part.isManual) {
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: buildCodeFromParts(numberingParts, val),
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
// auto 파트: 회색 배경 읽기전용
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<span className={cn(
|
||||
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
|
||||
isFirst && "rounded-l-[5px]",
|
||||
isLast && "rounded-r-[5px]",
|
||||
)}>
|
||||
{part.value}
|
||||
</span>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
placeholder="자동 채번"
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
)
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
@@ -548,8 +855,7 @@ export default function ItemInfoPage() {
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
|
||||
disabled={field.disabled && !isEditMode}
|
||||
placeholder={field.placeholder || field.label}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -412,21 +412,22 @@ export default function BomManagementPage() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// item_info의 division 카테고리
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/division/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results["division"] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
for (const itemCol of ["division", "unit"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results[itemCol] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setCategoryOptions(results);
|
||||
} catch {}
|
||||
@@ -1122,12 +1123,11 @@ export default function BomManagementPage() {
|
||||
}
|
||||
|
||||
// 현재 버전 ID 가져오기
|
||||
let versionId = currentVersionId;
|
||||
if (!versionId && bomId) {
|
||||
let versionId: string | null = null;
|
||||
if (bomId) {
|
||||
try {
|
||||
const verRes = await apiClient.get(`/bom/${bomId}/versions`);
|
||||
const verData = verRes.data?.data || verRes.data;
|
||||
versionId = verData?.currentVersionId || null;
|
||||
versionId = verRes.data?.currentVersionId || null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1193,7 +1193,20 @@ export default function BomManagementPage() {
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
}
|
||||
setItemSearchResults(rows);
|
||||
const resolved = rows.map((r: any) => {
|
||||
const out = { ...r };
|
||||
if (out.division) {
|
||||
out.division = out.division.split(",").map((c: string) => {
|
||||
const t = c.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === t)?.label || t;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
}
|
||||
if (out.unit) {
|
||||
out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
setItemSearchResults(resolved);
|
||||
} catch {
|
||||
toast.error("품목 검색에 실패했어요");
|
||||
} finally {
|
||||
@@ -1201,14 +1214,20 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUnit = (code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
const unitLabel = resolveUnit(item.unit);
|
||||
if (itemSearchTarget === "master") {
|
||||
setMasterForm((prev) => ({
|
||||
...prev,
|
||||
item_id: item.id,
|
||||
item_code: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
unit: item.unit || prev.unit,
|
||||
unit: unitLabel || prev.unit,
|
||||
}));
|
||||
setShowItemSearchModal(false);
|
||||
} else {
|
||||
@@ -1220,7 +1239,7 @@ export default function BomManagementPage() {
|
||||
item_number: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
quantity: "1",
|
||||
unit: item.unit || "",
|
||||
unit: unitLabel,
|
||||
process_type: "",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
|
||||
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (res.success) {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type RoutingVersion,
|
||||
} from "@/lib/api/processInfo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
function normalizeDefaultFlag(v: RoutingVersion): boolean {
|
||||
const raw = v.is_default as unknown;
|
||||
@@ -210,6 +211,7 @@ export function ItemRoutingTab() {
|
||||
return;
|
||||
}
|
||||
const list = res.data.map((v) => ({ ...v, is_default: normalizeDefaultFlag(v) }));
|
||||
list.sort((a, b) => (a.is_default === b.is_default ? 0 : a.is_default ? -1 : 1));
|
||||
setVersions(list);
|
||||
const preferred = preferVersionId ? list.find((v) => v.id === preferVersionId) : undefined;
|
||||
const def = list.find((v) => v.is_default);
|
||||
@@ -399,6 +401,38 @@ export function ItemRoutingTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// 선택 버전이 기본인지
|
||||
const selectedVersionIsDefault = useMemo(() => {
|
||||
if (!selectedVersionId) return false;
|
||||
const v = versions.find((v) => v.id === selectedVersionId);
|
||||
return v ? normalizeDefaultFlag(v) : false;
|
||||
}, [selectedVersionId, versions]);
|
||||
|
||||
// 기본 라우팅으로 설정
|
||||
const handleSetDefaultVersion = async () => {
|
||||
if (!selectedVersionId || !selectedItem) return;
|
||||
try {
|
||||
// 기존 기본 해제
|
||||
for (const v of versions) {
|
||||
if (normalizeDefaultFlag(v) && v.id !== selectedVersionId) {
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: v.id },
|
||||
updatedData: { is_default: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
// 선택 버전 기본 설정
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: selectedVersionId },
|
||||
updatedData: { is_default: true },
|
||||
});
|
||||
toast.success("기본 라우팅으로 설정했어요");
|
||||
await loadVersions(selectedItem, selectedVersionId);
|
||||
} catch {
|
||||
toast.error("기본 설정에 실패했어요");
|
||||
}
|
||||
};
|
||||
|
||||
const submitNewVersion = async () => {
|
||||
if (!selectedItem) return;
|
||||
const name = versionName.trim();
|
||||
@@ -639,6 +673,12 @@ export function ItemRoutingTab() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedVersionId && !selectedVersionIsDefault && (
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={handleSetDefaultVersion}>
|
||||
<Star className="h-3.5 w-3.5 fill-warning text-warning" />
|
||||
기본 설정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -249,7 +249,7 @@ export default function SalesOrderPage() {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
|
||||
} catch { /* skip */ }
|
||||
// 사용자 목록
|
||||
try {
|
||||
@@ -608,6 +608,24 @@ export default function SalesOrderPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
|
||||
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
const partnerId = masterForm.partner_id;
|
||||
let customerItemIds: Set<string> | null = null;
|
||||
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
@@ -615,6 +633,12 @@ export default function SalesOrderPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
let allRows = resData?.data || resData?.rows || [];
|
||||
|
||||
// 거래처우선일 때 연결된 품목만 표시
|
||||
if (customerItemIds) {
|
||||
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
|
||||
}
|
||||
|
||||
// 관리품목 필터 (코드/라벨 혼재 대응)
|
||||
if (itemSearchDivision !== "all") {
|
||||
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
|
||||
@@ -678,7 +702,8 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const _n = new Date();
|
||||
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
|
||||
for (const p of prices) {
|
||||
const start = p.start_date || "";
|
||||
const end = p.end_date || "";
|
||||
@@ -768,7 +793,8 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const _n = new Date();
|
||||
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
|
||||
const priceMap: Record<string, string> = {};
|
||||
for (const p of prices) {
|
||||
if (p.start_date && p.start_date > today) continue;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
|
||||
];
|
||||
|
||||
const FORM_FIELDS = [
|
||||
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
|
||||
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
{ key: "division", label: "관리품목", type: "multi-category" },
|
||||
{ key: "type", label: "품목구분", type: "category" },
|
||||
@@ -209,6 +209,123 @@ export default function ItemInfoPage() {
|
||||
// 선택된 행
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
|
||||
const [manualInputValue, setManualInputValue] = useState<string>("");
|
||||
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
|
||||
const numberingRuleIdRef = useRef<string | null>(null);
|
||||
|
||||
// 프리뷰 코드에서 각 파트별 표시값을 추출
|
||||
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
|
||||
if (!previewCode || !rule?.parts) return [];
|
||||
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const globalSep = rule.separator || "";
|
||||
|
||||
// 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외)
|
||||
const partMeta = sorted.map((part: any, idx: number) => {
|
||||
const sep = idx < sorted.length - 1
|
||||
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
|
||||
: "";
|
||||
const config = part.autoConfig || {};
|
||||
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
|
||||
switch (part.partType) {
|
||||
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
|
||||
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
|
||||
case "date": {
|
||||
const now = new Date();
|
||||
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
|
||||
const fmt = config.dateFormat || "YYYYMMDD";
|
||||
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
|
||||
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
|
||||
}
|
||||
// sequence, category: 알 수 없으므로 프리뷰 코드에서 추출
|
||||
default: return { known: false, sep, isManual: false, partType: part.partType };
|
||||
}
|
||||
});
|
||||
|
||||
// 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출
|
||||
let remaining = previewCode;
|
||||
const results: { value: string; isManual: boolean; separator: string }[] = [];
|
||||
|
||||
for (let i = 0; i < partMeta.length; i++) {
|
||||
const meta = partMeta[i];
|
||||
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
|
||||
|
||||
if (meta.isManual) {
|
||||
// manual 파트: "____" 마커 찾아서 스킵
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) {
|
||||
remaining = remaining.substring(markerIdx + 4);
|
||||
// 이 파트 뒤 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: "", isManual: true, separator: meta.sep });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (meta.known) {
|
||||
// 알려진 값: 프리뷰 코드에서 해당 값을 소비
|
||||
const valIdx = remaining.indexOf(meta.value);
|
||||
if (valIdx >= 0) {
|
||||
remaining = remaining.substring(valIdx + meta.value.length);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: meta.value, isManual: false, separator: meta.sep });
|
||||
} else {
|
||||
// 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색
|
||||
let endIdx = remaining.length;
|
||||
|
||||
if (meta.sep) {
|
||||
// 이 파트 뒤 구분자로 끝나는 지점 찾기
|
||||
// 단, 다음 파트의 시작을 기준으로 역방향 탐색
|
||||
if (nextMeta) {
|
||||
// 다음 파트가 known이면 그 값이 시작되는 위치 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
// 구분자 + 다음 값 패턴으로 찾기
|
||||
const pattern = meta.sep + nextMeta.value;
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
// 다음이 manual이면 구분자 + "____" 패턴
|
||||
const pattern = meta.sep + "____";
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else {
|
||||
// 구분자로 분리
|
||||
const sepIdx = remaining.indexOf(meta.sep);
|
||||
if (sepIdx >= 0) endIdx = sepIdx;
|
||||
}
|
||||
}
|
||||
} else if (nextMeta) {
|
||||
// 구분자 없이 다음 파트의 알려진 값으로 경계 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
const valIdx = remaining.indexOf(nextMeta.value);
|
||||
if (valIdx >= 0) endIdx = valIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) endIdx = markerIdx;
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = remaining.substring(0, endIdx);
|
||||
remaining = remaining.substring(endIdx);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
results.push({ value: extracted, isManual: false, separator: meta.sep });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -288,33 +405,73 @@ export default function ItemInfoPage() {
|
||||
}, [fetchItems]);
|
||||
|
||||
// 채번 미리보기 로드
|
||||
const loadNumberingPreview = async () => {
|
||||
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
const rule = ruleRes.data?.data;
|
||||
if (rule?.ruleId) {
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
|
||||
return previewRes.data?.data?.generatedCode || "";
|
||||
setIsNumberingLoading(true);
|
||||
|
||||
// 규칙 조회 (캐싱)
|
||||
let rule = numberingRule;
|
||||
if (!rule) {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
rule = ruleRes.data?.data;
|
||||
if (rule) {
|
||||
setNumberingRule(rule);
|
||||
numberingRuleIdRef.current = rule.ruleId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
|
||||
// preview 호출 (formData + manualInputValue 전달)
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
|
||||
formData: currentFormData || {},
|
||||
manualInputValue: currentManualValue || undefined,
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
return "";
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
};
|
||||
|
||||
// 파트 값으로부터 전체 코드 조합 (수동 입력값 포함)
|
||||
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
|
||||
return parts.map((p, idx) => {
|
||||
const val = p.isManual ? manualVal : p.value;
|
||||
const sep = idx < parts.length - 1 ? p.separator : "";
|
||||
return val + sep;
|
||||
}).join("");
|
||||
};
|
||||
|
||||
// 등록 모달 열기
|
||||
const openRegisterModal = async () => {
|
||||
setFormData({});
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 채번 컬럼 자동 로드
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
// 채번 미리보기
|
||||
const result = await loadNumberingPreview({});
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const openEditModal = (item: any) => {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -325,13 +482,56 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const result = await loadNumberingPreview(rest);
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 변경 시 채번 preview 재호출
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
|
||||
const hasCategoryPart = numberingRule?.parts?.some(
|
||||
(p: any) => p.partType === "category" && p.generationMethod === "auto"
|
||||
);
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
|
||||
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [manualInputValue]);
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.item_name) {
|
||||
@@ -342,6 +542,7 @@ export default function ItemInfoPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && editId) {
|
||||
// 수정: item_number는 변경하지 않음
|
||||
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
|
||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||
originalData: { id: editId },
|
||||
@@ -349,8 +550,38 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
// 신규 등록: allocateCode 호출하여 실제 순번 확보
|
||||
let finalItemNumber = formData.item_number || "";
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual && manualInputValue
|
||||
? manualInputValue
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
|
||||
{ formData, userInputCode }
|
||||
);
|
||||
|
||||
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
|
||||
finalItemNumber = allocRes.data.data.generatedCode;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("채번 할당 실패:", err);
|
||||
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { id, created_date, updated_date, ...insertFields } = formData;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
...insertFields,
|
||||
item_number: finalItemNumber,
|
||||
});
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
@@ -484,7 +715,15 @@ export default function ItemInfoPage() {
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
|
||||
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||
@@ -534,6 +773,74 @@ export default function ItemInfoPage() {
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "numbering" ? (
|
||||
// 채번 세그먼트 UI
|
||||
isEditMode ? (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading && numberingParts.length === 0 ? (
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">생성 중...</span>
|
||||
</div>
|
||||
) : numberingParts.some(p => p.isManual) ? (
|
||||
// 파트별 세그먼트 렌더링 (수동 입력 파트 있음)
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{numberingParts.map((part, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === numberingParts.length - 1;
|
||||
if (part.isManual) {
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: buildCodeFromParts(numberingParts, val),
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
// auto 파트: 회색 배경 읽기전용
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<span className={cn(
|
||||
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
|
||||
isFirst && "rounded-l-[5px]",
|
||||
isLast && "rounded-r-[5px]",
|
||||
)}>
|
||||
{part.value}
|
||||
</span>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
placeholder="자동 채번"
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
)
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
@@ -548,8 +855,7 @@ export default function ItemInfoPage() {
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
|
||||
disabled={field.disabled && !isEditMode}
|
||||
placeholder={field.placeholder || field.label}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -412,21 +412,22 @@ export default function BomManagementPage() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// item_info의 division 카테고리
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/division/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results["division"] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
for (const itemCol of ["division", "unit"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results[itemCol] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setCategoryOptions(results);
|
||||
} catch {}
|
||||
@@ -1122,12 +1123,11 @@ export default function BomManagementPage() {
|
||||
}
|
||||
|
||||
// 현재 버전 ID 가져오기
|
||||
let versionId = currentVersionId;
|
||||
if (!versionId && bomId) {
|
||||
let versionId: string | null = null;
|
||||
if (bomId) {
|
||||
try {
|
||||
const verRes = await apiClient.get(`/bom/${bomId}/versions`);
|
||||
const verData = verRes.data?.data || verRes.data;
|
||||
versionId = verData?.currentVersionId || null;
|
||||
versionId = verRes.data?.currentVersionId || null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1193,7 +1193,20 @@ export default function BomManagementPage() {
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
}
|
||||
setItemSearchResults(rows);
|
||||
const resolved = rows.map((r: any) => {
|
||||
const out = { ...r };
|
||||
if (out.division) {
|
||||
out.division = out.division.split(",").map((c: string) => {
|
||||
const t = c.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === t)?.label || t;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
}
|
||||
if (out.unit) {
|
||||
out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
setItemSearchResults(resolved);
|
||||
} catch {
|
||||
toast.error("품목 검색에 실패했어요");
|
||||
} finally {
|
||||
@@ -1201,14 +1214,20 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUnit = (code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
const unitLabel = resolveUnit(item.unit);
|
||||
if (itemSearchTarget === "master") {
|
||||
setMasterForm((prev) => ({
|
||||
...prev,
|
||||
item_id: item.id,
|
||||
item_code: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
unit: item.unit || prev.unit,
|
||||
unit: unitLabel || prev.unit,
|
||||
}));
|
||||
setShowItemSearchModal(false);
|
||||
} else {
|
||||
@@ -1220,7 +1239,7 @@ export default function BomManagementPage() {
|
||||
item_number: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
quantity: "1",
|
||||
unit: item.unit || "",
|
||||
unit: unitLabel,
|
||||
process_type: "",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
|
||||
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (res.success) {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type RoutingVersion,
|
||||
} from "@/lib/api/processInfo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
function normalizeDefaultFlag(v: RoutingVersion): boolean {
|
||||
const raw = v.is_default as unknown;
|
||||
@@ -210,6 +211,7 @@ export function ItemRoutingTab() {
|
||||
return;
|
||||
}
|
||||
const list = res.data.map((v) => ({ ...v, is_default: normalizeDefaultFlag(v) }));
|
||||
list.sort((a, b) => (a.is_default === b.is_default ? 0 : a.is_default ? -1 : 1));
|
||||
setVersions(list);
|
||||
const preferred = preferVersionId ? list.find((v) => v.id === preferVersionId) : undefined;
|
||||
const def = list.find((v) => v.is_default);
|
||||
@@ -399,6 +401,38 @@ export function ItemRoutingTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// 선택 버전이 기본인지
|
||||
const selectedVersionIsDefault = useMemo(() => {
|
||||
if (!selectedVersionId) return false;
|
||||
const v = versions.find((v) => v.id === selectedVersionId);
|
||||
return v ? normalizeDefaultFlag(v) : false;
|
||||
}, [selectedVersionId, versions]);
|
||||
|
||||
// 기본 라우팅으로 설정
|
||||
const handleSetDefaultVersion = async () => {
|
||||
if (!selectedVersionId || !selectedItem) return;
|
||||
try {
|
||||
// 기존 기본 해제
|
||||
for (const v of versions) {
|
||||
if (normalizeDefaultFlag(v) && v.id !== selectedVersionId) {
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: v.id },
|
||||
updatedData: { is_default: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
// 선택 버전 기본 설정
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: selectedVersionId },
|
||||
updatedData: { is_default: true },
|
||||
});
|
||||
toast.success("기본 라우팅으로 설정했어요");
|
||||
await loadVersions(selectedItem, selectedVersionId);
|
||||
} catch {
|
||||
toast.error("기본 설정에 실패했어요");
|
||||
}
|
||||
};
|
||||
|
||||
const submitNewVersion = async () => {
|
||||
if (!selectedItem) return;
|
||||
const name = versionName.trim();
|
||||
@@ -639,6 +673,12 @@ export function ItemRoutingTab() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedVersionId && !selectedVersionIsDefault && (
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={handleSetDefaultVersion}>
|
||||
<Star className="h-3.5 w-3.5 fill-warning text-warning" />
|
||||
기본 설정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function JeilGlassOrderPage() {
|
||||
const custs = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({
|
||||
code: c.customer_code,
|
||||
label: `${c.customer_name} (${c.customer_code})`,
|
||||
label: c.customer_name,
|
||||
}));
|
||||
} catch { /* skip */ }
|
||||
// 담당자
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
|
||||
];
|
||||
|
||||
const FORM_FIELDS = [
|
||||
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
|
||||
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
{ key: "division", label: "관리품목", type: "multi-category" },
|
||||
{ key: "type", label: "품목구분", type: "category" },
|
||||
@@ -209,6 +209,123 @@ export default function ItemInfoPage() {
|
||||
// 선택된 행
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
|
||||
const [manualInputValue, setManualInputValue] = useState<string>("");
|
||||
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
|
||||
const numberingRuleIdRef = useRef<string | null>(null);
|
||||
|
||||
// 프리뷰 코드에서 각 파트별 표시값을 추출
|
||||
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
|
||||
if (!previewCode || !rule?.parts) return [];
|
||||
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const globalSep = rule.separator || "";
|
||||
|
||||
// 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외)
|
||||
const partMeta = sorted.map((part: any, idx: number) => {
|
||||
const sep = idx < sorted.length - 1
|
||||
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
|
||||
: "";
|
||||
const config = part.autoConfig || {};
|
||||
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
|
||||
switch (part.partType) {
|
||||
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
|
||||
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
|
||||
case "date": {
|
||||
const now = new Date();
|
||||
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
|
||||
const fmt = config.dateFormat || "YYYYMMDD";
|
||||
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
|
||||
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
|
||||
}
|
||||
// sequence, category: 알 수 없으므로 프리뷰 코드에서 추출
|
||||
default: return { known: false, sep, isManual: false, partType: part.partType };
|
||||
}
|
||||
});
|
||||
|
||||
// 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출
|
||||
let remaining = previewCode;
|
||||
const results: { value: string; isManual: boolean; separator: string }[] = [];
|
||||
|
||||
for (let i = 0; i < partMeta.length; i++) {
|
||||
const meta = partMeta[i];
|
||||
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
|
||||
|
||||
if (meta.isManual) {
|
||||
// manual 파트: "____" 마커 찾아서 스킵
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) {
|
||||
remaining = remaining.substring(markerIdx + 4);
|
||||
// 이 파트 뒤 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: "", isManual: true, separator: meta.sep });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (meta.known) {
|
||||
// 알려진 값: 프리뷰 코드에서 해당 값을 소비
|
||||
const valIdx = remaining.indexOf(meta.value);
|
||||
if (valIdx >= 0) {
|
||||
remaining = remaining.substring(valIdx + meta.value.length);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: meta.value, isManual: false, separator: meta.sep });
|
||||
} else {
|
||||
// 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색
|
||||
let endIdx = remaining.length;
|
||||
|
||||
if (meta.sep) {
|
||||
// 이 파트 뒤 구분자로 끝나는 지점 찾기
|
||||
// 단, 다음 파트의 시작을 기준으로 역방향 탐색
|
||||
if (nextMeta) {
|
||||
// 다음 파트가 known이면 그 값이 시작되는 위치 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
// 구분자 + 다음 값 패턴으로 찾기
|
||||
const pattern = meta.sep + nextMeta.value;
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
// 다음이 manual이면 구분자 + "____" 패턴
|
||||
const pattern = meta.sep + "____";
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else {
|
||||
// 구분자로 분리
|
||||
const sepIdx = remaining.indexOf(meta.sep);
|
||||
if (sepIdx >= 0) endIdx = sepIdx;
|
||||
}
|
||||
}
|
||||
} else if (nextMeta) {
|
||||
// 구분자 없이 다음 파트의 알려진 값으로 경계 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
const valIdx = remaining.indexOf(nextMeta.value);
|
||||
if (valIdx >= 0) endIdx = valIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) endIdx = markerIdx;
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = remaining.substring(0, endIdx);
|
||||
remaining = remaining.substring(endIdx);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
results.push({ value: extracted, isManual: false, separator: meta.sep });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -288,33 +405,73 @@ export default function ItemInfoPage() {
|
||||
}, [fetchItems]);
|
||||
|
||||
// 채번 미리보기 로드
|
||||
const loadNumberingPreview = async () => {
|
||||
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
const rule = ruleRes.data?.data;
|
||||
if (rule?.ruleId) {
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
|
||||
return previewRes.data?.data?.generatedCode || "";
|
||||
setIsNumberingLoading(true);
|
||||
|
||||
// 규칙 조회 (캐싱)
|
||||
let rule = numberingRule;
|
||||
if (!rule) {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
rule = ruleRes.data?.data;
|
||||
if (rule) {
|
||||
setNumberingRule(rule);
|
||||
numberingRuleIdRef.current = rule.ruleId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
|
||||
// preview 호출 (formData + manualInputValue 전달)
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
|
||||
formData: currentFormData || {},
|
||||
manualInputValue: currentManualValue || undefined,
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
return "";
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
};
|
||||
|
||||
// 파트 값으로부터 전체 코드 조합 (수동 입력값 포함)
|
||||
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
|
||||
return parts.map((p, idx) => {
|
||||
const val = p.isManual ? manualVal : p.value;
|
||||
const sep = idx < parts.length - 1 ? p.separator : "";
|
||||
return val + sep;
|
||||
}).join("");
|
||||
};
|
||||
|
||||
// 등록 모달 열기
|
||||
const openRegisterModal = async () => {
|
||||
setFormData({});
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 채번 컬럼 자동 로드
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
// 채번 미리보기
|
||||
const result = await loadNumberingPreview({});
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const openEditModal = (item: any) => {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -325,13 +482,56 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const result = await loadNumberingPreview(rest);
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 변경 시 채번 preview 재호출
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
|
||||
const hasCategoryPart = numberingRule?.parts?.some(
|
||||
(p: any) => p.partType === "category" && p.generationMethod === "auto"
|
||||
);
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
|
||||
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [manualInputValue]);
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.item_name) {
|
||||
@@ -342,6 +542,7 @@ export default function ItemInfoPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && editId) {
|
||||
// 수정: item_number는 변경하지 않음
|
||||
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
|
||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||
originalData: { id: editId },
|
||||
@@ -349,8 +550,38 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
// 신규 등록: allocateCode 호출하여 실제 순번 확보
|
||||
let finalItemNumber = formData.item_number || "";
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual && manualInputValue
|
||||
? manualInputValue
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
|
||||
{ formData, userInputCode }
|
||||
);
|
||||
|
||||
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
|
||||
finalItemNumber = allocRes.data.data.generatedCode;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("채번 할당 실패:", err);
|
||||
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { id, created_date, updated_date, ...insertFields } = formData;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
...insertFields,
|
||||
item_number: finalItemNumber,
|
||||
});
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
@@ -484,7 +715,15 @@ export default function ItemInfoPage() {
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
|
||||
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||
@@ -534,6 +773,74 @@ export default function ItemInfoPage() {
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "numbering" ? (
|
||||
// 채번 세그먼트 UI
|
||||
isEditMode ? (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading && numberingParts.length === 0 ? (
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">생성 중...</span>
|
||||
</div>
|
||||
) : numberingParts.some(p => p.isManual) ? (
|
||||
// 파트별 세그먼트 렌더링 (수동 입력 파트 있음)
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{numberingParts.map((part, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === numberingParts.length - 1;
|
||||
if (part.isManual) {
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: buildCodeFromParts(numberingParts, val),
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
// auto 파트: 회색 배경 읽기전용
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<span className={cn(
|
||||
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
|
||||
isFirst && "rounded-l-[5px]",
|
||||
isLast && "rounded-r-[5px]",
|
||||
)}>
|
||||
{part.value}
|
||||
</span>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
placeholder="자동 채번"
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
)
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
@@ -548,8 +855,7 @@ export default function ItemInfoPage() {
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
|
||||
disabled={field.disabled && !isEditMode}
|
||||
placeholder={field.placeholder || field.label}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -412,21 +412,23 @@ export default function BomManagementPage() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// item_info의 division 카테고리
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/division/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results["division"] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
// item_info의 division, unit 카테고리
|
||||
for (const itemCol of ["division", "unit"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results[itemCol] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setCategoryOptions(results);
|
||||
} catch {}
|
||||
@@ -1121,13 +1123,12 @@ export default function BomManagementPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 버전 ID 가져오기
|
||||
let versionId = currentVersionId;
|
||||
if (!versionId && bomId) {
|
||||
// 현재 버전 ID 가져오기 (initialize-version 후 최신 값)
|
||||
let versionId: string | null = null;
|
||||
if (bomId) {
|
||||
try {
|
||||
const verRes = await apiClient.get(`/bom/${bomId}/versions`);
|
||||
const verData = verRes.data?.data || verRes.data;
|
||||
versionId = verData?.currentVersionId || null;
|
||||
versionId = verRes.data?.currentVersionId || null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1193,7 +1194,20 @@ export default function BomManagementPage() {
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
}
|
||||
setItemSearchResults(rows);
|
||||
const resolved = rows.map((r: any) => {
|
||||
const out = { ...r };
|
||||
if (out.division) {
|
||||
out.division = out.division.split(",").map((c: string) => {
|
||||
const t = c.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === t)?.label || t;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
}
|
||||
if (out.unit) {
|
||||
out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
setItemSearchResults(resolved);
|
||||
} catch {
|
||||
toast.error("품목 검색에 실패했어요");
|
||||
} finally {
|
||||
@@ -1201,14 +1215,20 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUnit = (code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
const unitLabel = resolveUnit(item.unit);
|
||||
if (itemSearchTarget === "master") {
|
||||
setMasterForm((prev) => ({
|
||||
...prev,
|
||||
item_id: item.id,
|
||||
item_code: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
unit: item.unit || prev.unit,
|
||||
unit: unitLabel || prev.unit,
|
||||
}));
|
||||
setShowItemSearchModal(false);
|
||||
} else {
|
||||
@@ -1220,7 +1240,7 @@ export default function BomManagementPage() {
|
||||
item_number: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
quantity: "1",
|
||||
unit: item.unit || "",
|
||||
unit: unitLabel,
|
||||
process_type: "",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
|
||||
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (res.success) {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type RoutingVersion,
|
||||
} from "@/lib/api/processInfo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
function normalizeDefaultFlag(v: RoutingVersion): boolean {
|
||||
const raw = v.is_default as unknown;
|
||||
@@ -210,6 +211,7 @@ export function ItemRoutingTab() {
|
||||
return;
|
||||
}
|
||||
const list = res.data.map((v) => ({ ...v, is_default: normalizeDefaultFlag(v) }));
|
||||
list.sort((a, b) => (a.is_default === b.is_default ? 0 : a.is_default ? -1 : 1));
|
||||
setVersions(list);
|
||||
const preferred = preferVersionId ? list.find((v) => v.id === preferVersionId) : undefined;
|
||||
const def = list.find((v) => v.is_default);
|
||||
@@ -399,6 +401,38 @@ export function ItemRoutingTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// 선택 버전이 기본인지
|
||||
const selectedVersionIsDefault = useMemo(() => {
|
||||
if (!selectedVersionId) return false;
|
||||
const v = versions.find((v) => v.id === selectedVersionId);
|
||||
return v ? normalizeDefaultFlag(v) : false;
|
||||
}, [selectedVersionId, versions]);
|
||||
|
||||
// 기본 라우팅으로 설정
|
||||
const handleSetDefaultVersion = async () => {
|
||||
if (!selectedVersionId || !selectedItem) return;
|
||||
try {
|
||||
// 기존 기본 해제
|
||||
for (const v of versions) {
|
||||
if (normalizeDefaultFlag(v) && v.id !== selectedVersionId) {
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: v.id },
|
||||
updatedData: { is_default: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
// 선택 버전 기본 설정
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: selectedVersionId },
|
||||
updatedData: { is_default: true },
|
||||
});
|
||||
toast.success("기본 라우팅으로 설정했어요");
|
||||
await loadVersions(selectedItem, selectedVersionId);
|
||||
} catch {
|
||||
toast.error("기본 설정에 실패했어요");
|
||||
}
|
||||
};
|
||||
|
||||
const submitNewVersion = async () => {
|
||||
if (!selectedItem) return;
|
||||
const name = versionName.trim();
|
||||
@@ -639,6 +673,12 @@ export function ItemRoutingTab() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedVersionId && !selectedVersionIsDefault && (
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={handleSetDefaultVersion}>
|
||||
<Star className="h-3.5 w-3.5 fill-warning text-warning" />
|
||||
기본 설정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -249,7 +249,7 @@ export default function SalesOrderPage() {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
|
||||
} catch { /* skip */ }
|
||||
// 사용자 목록
|
||||
try {
|
||||
@@ -608,6 +608,24 @@ export default function SalesOrderPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
|
||||
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
const partnerId = masterForm.partner_id;
|
||||
let customerItemIds: Set<string> | null = null;
|
||||
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
@@ -615,6 +633,12 @@ export default function SalesOrderPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
let allRows = resData?.data || resData?.rows || [];
|
||||
|
||||
// 거래처우선일 때 연결된 품목만 표시
|
||||
if (customerItemIds) {
|
||||
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
|
||||
}
|
||||
|
||||
// 관리품목 필터 (코드/라벨 혼재 대응)
|
||||
if (itemSearchDivision !== "all") {
|
||||
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
|
||||
@@ -678,7 +702,8 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const _n = new Date();
|
||||
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
|
||||
for (const p of prices) {
|
||||
const start = p.start_date || "";
|
||||
const end = p.end_date || "";
|
||||
@@ -768,7 +793,8 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const _n = new Date();
|
||||
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
|
||||
const priceMap: Record<string, string> = {};
|
||||
for (const p of prices) {
|
||||
if (p.start_date && p.start_date > today) continue;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
|
||||
];
|
||||
|
||||
const FORM_FIELDS = [
|
||||
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
|
||||
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
{ key: "division", label: "관리품목", type: "multi-category" },
|
||||
{ key: "type", label: "품목구분", type: "category" },
|
||||
@@ -209,6 +209,123 @@ export default function ItemInfoPage() {
|
||||
// 선택된 행
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
|
||||
const [manualInputValue, setManualInputValue] = useState<string>("");
|
||||
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
|
||||
const numberingRuleIdRef = useRef<string | null>(null);
|
||||
|
||||
// 프리뷰 코드에서 각 파트별 표시값을 추출
|
||||
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
|
||||
if (!previewCode || !rule?.parts) return [];
|
||||
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const globalSep = rule.separator || "";
|
||||
|
||||
// 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외)
|
||||
const partMeta = sorted.map((part: any, idx: number) => {
|
||||
const sep = idx < sorted.length - 1
|
||||
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
|
||||
: "";
|
||||
const config = part.autoConfig || {};
|
||||
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
|
||||
switch (part.partType) {
|
||||
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
|
||||
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
|
||||
case "date": {
|
||||
const now = new Date();
|
||||
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
|
||||
const fmt = config.dateFormat || "YYYYMMDD";
|
||||
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
|
||||
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
|
||||
}
|
||||
// sequence, category: 알 수 없으므로 프리뷰 코드에서 추출
|
||||
default: return { known: false, sep, isManual: false, partType: part.partType };
|
||||
}
|
||||
});
|
||||
|
||||
// 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출
|
||||
let remaining = previewCode;
|
||||
const results: { value: string; isManual: boolean; separator: string }[] = [];
|
||||
|
||||
for (let i = 0; i < partMeta.length; i++) {
|
||||
const meta = partMeta[i];
|
||||
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
|
||||
|
||||
if (meta.isManual) {
|
||||
// manual 파트: "____" 마커 찾아서 스킵
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) {
|
||||
remaining = remaining.substring(markerIdx + 4);
|
||||
// 이 파트 뒤 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: "", isManual: true, separator: meta.sep });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (meta.known) {
|
||||
// 알려진 값: 프리뷰 코드에서 해당 값을 소비
|
||||
const valIdx = remaining.indexOf(meta.value);
|
||||
if (valIdx >= 0) {
|
||||
remaining = remaining.substring(valIdx + meta.value.length);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: meta.value, isManual: false, separator: meta.sep });
|
||||
} else {
|
||||
// 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색
|
||||
let endIdx = remaining.length;
|
||||
|
||||
if (meta.sep) {
|
||||
// 이 파트 뒤 구분자로 끝나는 지점 찾기
|
||||
// 단, 다음 파트의 시작을 기준으로 역방향 탐색
|
||||
if (nextMeta) {
|
||||
// 다음 파트가 known이면 그 값이 시작되는 위치 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
// 구분자 + 다음 값 패턴으로 찾기
|
||||
const pattern = meta.sep + nextMeta.value;
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
// 다음이 manual이면 구분자 + "____" 패턴
|
||||
const pattern = meta.sep + "____";
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else {
|
||||
// 구분자로 분리
|
||||
const sepIdx = remaining.indexOf(meta.sep);
|
||||
if (sepIdx >= 0) endIdx = sepIdx;
|
||||
}
|
||||
}
|
||||
} else if (nextMeta) {
|
||||
// 구분자 없이 다음 파트의 알려진 값으로 경계 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
const valIdx = remaining.indexOf(nextMeta.value);
|
||||
if (valIdx >= 0) endIdx = valIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) endIdx = markerIdx;
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = remaining.substring(0, endIdx);
|
||||
remaining = remaining.substring(endIdx);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
results.push({ value: extracted, isManual: false, separator: meta.sep });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -288,33 +405,73 @@ export default function ItemInfoPage() {
|
||||
}, [fetchItems]);
|
||||
|
||||
// 채번 미리보기 로드
|
||||
const loadNumberingPreview = async () => {
|
||||
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
const rule = ruleRes.data?.data;
|
||||
if (rule?.ruleId) {
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
|
||||
return previewRes.data?.data?.generatedCode || "";
|
||||
setIsNumberingLoading(true);
|
||||
|
||||
// 규칙 조회 (캐싱)
|
||||
let rule = numberingRule;
|
||||
if (!rule) {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
rule = ruleRes.data?.data;
|
||||
if (rule) {
|
||||
setNumberingRule(rule);
|
||||
numberingRuleIdRef.current = rule.ruleId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
|
||||
// preview 호출 (formData + manualInputValue 전달)
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
|
||||
formData: currentFormData || {},
|
||||
manualInputValue: currentManualValue || undefined,
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
return "";
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
};
|
||||
|
||||
// 파트 값으로부터 전체 코드 조합 (수동 입력값 포함)
|
||||
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
|
||||
return parts.map((p, idx) => {
|
||||
const val = p.isManual ? manualVal : p.value;
|
||||
const sep = idx < parts.length - 1 ? p.separator : "";
|
||||
return val + sep;
|
||||
}).join("");
|
||||
};
|
||||
|
||||
// 등록 모달 열기
|
||||
const openRegisterModal = async () => {
|
||||
setFormData({});
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 채번 컬럼 자동 로드
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
// 채번 미리보기
|
||||
const result = await loadNumberingPreview({});
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const openEditModal = (item: any) => {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -325,13 +482,56 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const result = await loadNumberingPreview(rest);
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 변경 시 채번 preview 재호출
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
|
||||
const hasCategoryPart = numberingRule?.parts?.some(
|
||||
(p: any) => p.partType === "category" && p.generationMethod === "auto"
|
||||
);
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
|
||||
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [manualInputValue]);
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.item_name) {
|
||||
@@ -342,6 +542,7 @@ export default function ItemInfoPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && editId) {
|
||||
// 수정: item_number는 변경하지 않음
|
||||
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
|
||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||
originalData: { id: editId },
|
||||
@@ -349,8 +550,38 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
// 신규 등록: allocateCode 호출하여 실제 순번 확보
|
||||
let finalItemNumber = formData.item_number || "";
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual && manualInputValue
|
||||
? manualInputValue
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
|
||||
{ formData, userInputCode }
|
||||
);
|
||||
|
||||
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
|
||||
finalItemNumber = allocRes.data.data.generatedCode;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("채번 할당 실패:", err);
|
||||
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { id, created_date, updated_date, ...insertFields } = formData;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
...insertFields,
|
||||
item_number: finalItemNumber,
|
||||
});
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
@@ -484,7 +715,15 @@ export default function ItemInfoPage() {
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
|
||||
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||
@@ -534,6 +773,74 @@ export default function ItemInfoPage() {
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "numbering" ? (
|
||||
// 채번 세그먼트 UI
|
||||
isEditMode ? (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading && numberingParts.length === 0 ? (
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">생성 중...</span>
|
||||
</div>
|
||||
) : numberingParts.some(p => p.isManual) ? (
|
||||
// 파트별 세그먼트 렌더링 (수동 입력 파트 있음)
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{numberingParts.map((part, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === numberingParts.length - 1;
|
||||
if (part.isManual) {
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: buildCodeFromParts(numberingParts, val),
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
// auto 파트: 회색 배경 읽기전용
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<span className={cn(
|
||||
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
|
||||
isFirst && "rounded-l-[5px]",
|
||||
isLast && "rounded-r-[5px]",
|
||||
)}>
|
||||
{part.value}
|
||||
</span>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
placeholder="자동 채번"
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
)
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
@@ -548,8 +855,7 @@ export default function ItemInfoPage() {
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
|
||||
disabled={field.disabled && !isEditMode}
|
||||
placeholder={field.placeholder || field.label}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -412,21 +412,22 @@ export default function BomManagementPage() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// item_info의 division 카테고리
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/division/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results["division"] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
for (const itemCol of ["division", "unit"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results[itemCol] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setCategoryOptions(results);
|
||||
} catch {}
|
||||
@@ -1121,13 +1122,11 @@ export default function BomManagementPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 버전 ID 가져오기
|
||||
let versionId = currentVersionId;
|
||||
if (!versionId && bomId) {
|
||||
let versionId: string | null = null;
|
||||
if (bomId) {
|
||||
try {
|
||||
const verRes = await apiClient.get(`/bom/${bomId}/versions`);
|
||||
const verData = verRes.data?.data || verRes.data;
|
||||
versionId = verData?.currentVersionId || null;
|
||||
versionId = verRes.data?.currentVersionId || null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1193,7 +1192,20 @@ export default function BomManagementPage() {
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
}
|
||||
setItemSearchResults(rows);
|
||||
const resolved = rows.map((r: any) => {
|
||||
const out = { ...r };
|
||||
if (out.division) {
|
||||
out.division = out.division.split(",").map((c: string) => {
|
||||
const t = c.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === t)?.label || t;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
}
|
||||
if (out.unit) {
|
||||
out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
setItemSearchResults(resolved);
|
||||
} catch {
|
||||
toast.error("품목 검색에 실패했어요");
|
||||
} finally {
|
||||
@@ -1201,14 +1213,20 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUnit = (code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
const unitLabel = resolveUnit(item.unit);
|
||||
if (itemSearchTarget === "master") {
|
||||
setMasterForm((prev) => ({
|
||||
...prev,
|
||||
item_id: item.id,
|
||||
item_code: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
unit: item.unit || prev.unit,
|
||||
unit: unitLabel || prev.unit,
|
||||
}));
|
||||
setShowItemSearchModal(false);
|
||||
} else {
|
||||
@@ -1220,7 +1238,7 @@ export default function BomManagementPage() {
|
||||
item_number: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
quantity: "1",
|
||||
unit: item.unit || "",
|
||||
unit: unitLabel,
|
||||
process_type: "",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
|
||||
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (res.success) {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type RoutingVersion,
|
||||
} from "@/lib/api/processInfo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
function normalizeDefaultFlag(v: RoutingVersion): boolean {
|
||||
const raw = v.is_default as unknown;
|
||||
@@ -210,6 +211,7 @@ export function ItemRoutingTab() {
|
||||
return;
|
||||
}
|
||||
const list = res.data.map((v) => ({ ...v, is_default: normalizeDefaultFlag(v) }));
|
||||
list.sort((a, b) => (a.is_default === b.is_default ? 0 : a.is_default ? -1 : 1));
|
||||
setVersions(list);
|
||||
const preferred = preferVersionId ? list.find((v) => v.id === preferVersionId) : undefined;
|
||||
const def = list.find((v) => v.is_default);
|
||||
@@ -399,6 +401,38 @@ export function ItemRoutingTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// 선택 버전이 기본인지
|
||||
const selectedVersionIsDefault = useMemo(() => {
|
||||
if (!selectedVersionId) return false;
|
||||
const v = versions.find((v) => v.id === selectedVersionId);
|
||||
return v ? normalizeDefaultFlag(v) : false;
|
||||
}, [selectedVersionId, versions]);
|
||||
|
||||
// 기본 라우팅으로 설정
|
||||
const handleSetDefaultVersion = async () => {
|
||||
if (!selectedVersionId || !selectedItem) return;
|
||||
try {
|
||||
// 기존 기본 해제
|
||||
for (const v of versions) {
|
||||
if (normalizeDefaultFlag(v) && v.id !== selectedVersionId) {
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: v.id },
|
||||
updatedData: { is_default: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
// 선택 버전 기본 설정
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: selectedVersionId },
|
||||
updatedData: { is_default: true },
|
||||
});
|
||||
toast.success("기본 라우팅으로 설정했어요");
|
||||
await loadVersions(selectedItem, selectedVersionId);
|
||||
} catch {
|
||||
toast.error("기본 설정에 실패했어요");
|
||||
}
|
||||
};
|
||||
|
||||
const submitNewVersion = async () => {
|
||||
if (!selectedItem) return;
|
||||
const name = versionName.trim();
|
||||
@@ -639,6 +673,12 @@ export function ItemRoutingTab() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedVersionId && !selectedVersionIsDefault && (
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={handleSetDefaultVersion}>
|
||||
<Star className="h-3.5 w-3.5 fill-warning text-warning" />
|
||||
기본 설정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -249,7 +249,7 @@ export default function SalesOrderPage() {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
|
||||
} catch { /* skip */ }
|
||||
// 사용자 목록
|
||||
try {
|
||||
@@ -608,6 +608,24 @@ export default function SalesOrderPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
|
||||
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
const partnerId = masterForm.partner_id;
|
||||
let customerItemIds: Set<string> | null = null;
|
||||
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
@@ -615,6 +633,12 @@ export default function SalesOrderPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
let allRows = resData?.data || resData?.rows || [];
|
||||
|
||||
// 거래처우선일 때 연결된 품목만 표시
|
||||
if (customerItemIds) {
|
||||
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
|
||||
}
|
||||
|
||||
// 관리품목 필터 (코드/라벨 혼재 대응)
|
||||
if (itemSearchDivision !== "all") {
|
||||
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
|
||||
@@ -678,7 +702,8 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const _n = new Date();
|
||||
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
|
||||
for (const p of prices) {
|
||||
const start = p.start_date || "";
|
||||
const end = p.end_date || "";
|
||||
@@ -768,7 +793,8 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const _n = new Date();
|
||||
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
|
||||
const priceMap: Record<string, string> = {};
|
||||
for (const p of prices) {
|
||||
if (p.start_date && p.start_date > today) continue;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
|
||||
];
|
||||
|
||||
const FORM_FIELDS = [
|
||||
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
|
||||
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
{ key: "division", label: "관리품목", type: "multi-category" },
|
||||
{ key: "type", label: "품목구분", type: "category" },
|
||||
@@ -209,6 +209,123 @@ export default function ItemInfoPage() {
|
||||
// 선택된 행
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
|
||||
const [manualInputValue, setManualInputValue] = useState<string>("");
|
||||
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
|
||||
const numberingRuleIdRef = useRef<string | null>(null);
|
||||
|
||||
// 프리뷰 코드에서 각 파트별 표시값을 추출
|
||||
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
|
||||
if (!previewCode || !rule?.parts) return [];
|
||||
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const globalSep = rule.separator || "";
|
||||
|
||||
// 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외)
|
||||
const partMeta = sorted.map((part: any, idx: number) => {
|
||||
const sep = idx < sorted.length - 1
|
||||
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
|
||||
: "";
|
||||
const config = part.autoConfig || {};
|
||||
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
|
||||
switch (part.partType) {
|
||||
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
|
||||
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
|
||||
case "date": {
|
||||
const now = new Date();
|
||||
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
|
||||
const fmt = config.dateFormat || "YYYYMMDD";
|
||||
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
|
||||
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
|
||||
}
|
||||
// sequence, category: 알 수 없으므로 프리뷰 코드에서 추출
|
||||
default: return { known: false, sep, isManual: false, partType: part.partType };
|
||||
}
|
||||
});
|
||||
|
||||
// 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출
|
||||
let remaining = previewCode;
|
||||
const results: { value: string; isManual: boolean; separator: string }[] = [];
|
||||
|
||||
for (let i = 0; i < partMeta.length; i++) {
|
||||
const meta = partMeta[i];
|
||||
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
|
||||
|
||||
if (meta.isManual) {
|
||||
// manual 파트: "____" 마커 찾아서 스킵
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) {
|
||||
remaining = remaining.substring(markerIdx + 4);
|
||||
// 이 파트 뒤 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: "", isManual: true, separator: meta.sep });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (meta.known) {
|
||||
// 알려진 값: 프리뷰 코드에서 해당 값을 소비
|
||||
const valIdx = remaining.indexOf(meta.value);
|
||||
if (valIdx >= 0) {
|
||||
remaining = remaining.substring(valIdx + meta.value.length);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
}
|
||||
results.push({ value: meta.value, isManual: false, separator: meta.sep });
|
||||
} else {
|
||||
// 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색
|
||||
let endIdx = remaining.length;
|
||||
|
||||
if (meta.sep) {
|
||||
// 이 파트 뒤 구분자로 끝나는 지점 찾기
|
||||
// 단, 다음 파트의 시작을 기준으로 역방향 탐색
|
||||
if (nextMeta) {
|
||||
// 다음 파트가 known이면 그 값이 시작되는 위치 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
// 구분자 + 다음 값 패턴으로 찾기
|
||||
const pattern = meta.sep + nextMeta.value;
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
// 다음이 manual이면 구분자 + "____" 패턴
|
||||
const pattern = meta.sep + "____";
|
||||
const patIdx = remaining.indexOf(pattern);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else {
|
||||
// 구분자로 분리
|
||||
const sepIdx = remaining.indexOf(meta.sep);
|
||||
if (sepIdx >= 0) endIdx = sepIdx;
|
||||
}
|
||||
}
|
||||
} else if (nextMeta) {
|
||||
// 구분자 없이 다음 파트의 알려진 값으로 경계 탐색
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
const valIdx = remaining.indexOf(nextMeta.value);
|
||||
if (valIdx >= 0) endIdx = valIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
const markerIdx = remaining.indexOf("____");
|
||||
if (markerIdx >= 0) endIdx = markerIdx;
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = remaining.substring(0, endIdx);
|
||||
remaining = remaining.substring(endIdx);
|
||||
// 구분자 소비
|
||||
if (meta.sep && remaining.startsWith(meta.sep)) {
|
||||
remaining = remaining.substring(meta.sep.length);
|
||||
}
|
||||
results.push({ value: extracted, isManual: false, separator: meta.sep });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -288,33 +405,73 @@ export default function ItemInfoPage() {
|
||||
}, [fetchItems]);
|
||||
|
||||
// 채번 미리보기 로드
|
||||
const loadNumberingPreview = async () => {
|
||||
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
const rule = ruleRes.data?.data;
|
||||
if (rule?.ruleId) {
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
|
||||
return previewRes.data?.data?.generatedCode || "";
|
||||
setIsNumberingLoading(true);
|
||||
|
||||
// 규칙 조회 (캐싱)
|
||||
let rule = numberingRule;
|
||||
if (!rule) {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
rule = ruleRes.data?.data;
|
||||
if (rule) {
|
||||
setNumberingRule(rule);
|
||||
numberingRuleIdRef.current = rule.ruleId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
|
||||
// preview 호출 (formData + manualInputValue 전달)
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
|
||||
formData: currentFormData || {},
|
||||
manualInputValue: currentManualValue || undefined,
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
return "";
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
};
|
||||
|
||||
// 파트 값으로부터 전체 코드 조합 (수동 입력값 포함)
|
||||
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
|
||||
return parts.map((p, idx) => {
|
||||
const val = p.isManual ? manualVal : p.value;
|
||||
const sep = idx < parts.length - 1 ? p.separator : "";
|
||||
return val + sep;
|
||||
}).join("");
|
||||
};
|
||||
|
||||
// 등록 모달 열기
|
||||
const openRegisterModal = async () => {
|
||||
setFormData({});
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 채번 컬럼 자동 로드
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
// 채번 미리보기
|
||||
const result = await loadNumberingPreview({});
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const openEditModal = (item: any) => {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -325,13 +482,56 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const result = await loadNumberingPreview(rest);
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 변경 시 채번 preview 재호출
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
|
||||
const hasCategoryPart = numberingRule?.parts?.some(
|
||||
(p: any) => p.partType === "category" && p.generationMethod === "auto"
|
||||
);
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
|
||||
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [manualInputValue]);
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.item_name) {
|
||||
@@ -342,6 +542,7 @@ export default function ItemInfoPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && editId) {
|
||||
// 수정: item_number는 변경하지 않음
|
||||
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
|
||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||
originalData: { id: editId },
|
||||
@@ -349,8 +550,38 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
// 신규 등록: allocateCode 호출하여 실제 순번 확보
|
||||
let finalItemNumber = formData.item_number || "";
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual && manualInputValue
|
||||
? manualInputValue
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
|
||||
{ formData, userInputCode }
|
||||
);
|
||||
|
||||
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
|
||||
finalItemNumber = allocRes.data.data.generatedCode;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("채번 할당 실패:", err);
|
||||
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { id, created_date, updated_date, ...insertFields } = formData;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
...insertFields,
|
||||
item_number: finalItemNumber,
|
||||
});
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
@@ -484,7 +715,15 @@ export default function ItemInfoPage() {
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
|
||||
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||
@@ -534,6 +773,74 @@ export default function ItemInfoPage() {
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "numbering" ? (
|
||||
// 채번 세그먼트 UI
|
||||
isEditMode ? (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading && numberingParts.length === 0 ? (
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">생성 중...</span>
|
||||
</div>
|
||||
) : numberingParts.some(p => p.isManual) ? (
|
||||
// 파트별 세그먼트 렌더링 (수동 입력 파트 있음)
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{numberingParts.map((part, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === numberingParts.length - 1;
|
||||
if (part.isManual) {
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: buildCodeFromParts(numberingParts, val),
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
// auto 파트: 회색 배경 읽기전용
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<span className={cn(
|
||||
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
|
||||
isFirst && "rounded-l-[5px]",
|
||||
isLast && "rounded-r-[5px]",
|
||||
)}>
|
||||
{part.value}
|
||||
</span>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
placeholder="자동 채번"
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
)
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
@@ -548,8 +855,7 @@ export default function ItemInfoPage() {
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
|
||||
disabled={field.disabled && !isEditMode}
|
||||
placeholder={field.placeholder || field.label}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -412,21 +412,22 @@ export default function BomManagementPage() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// item_info의 division 카테고리
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/division/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results["division"] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
for (const itemCol of ["division", "unit"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
const flatten = (items: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
|
||||
if (item.children?.length) result.push(...flatten(item.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
results[itemCol] = flatten(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setCategoryOptions(results);
|
||||
} catch {}
|
||||
@@ -1122,12 +1123,11 @@ export default function BomManagementPage() {
|
||||
}
|
||||
|
||||
// 현재 버전 ID 가져오기
|
||||
let versionId = currentVersionId;
|
||||
if (!versionId && bomId) {
|
||||
let versionId: string | null = null;
|
||||
if (bomId) {
|
||||
try {
|
||||
const verRes = await apiClient.get(`/bom/${bomId}/versions`);
|
||||
const verData = verRes.data?.data || verRes.data;
|
||||
versionId = verData?.currentVersionId || null;
|
||||
versionId = verRes.data?.currentVersionId || null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1193,7 +1193,20 @@ export default function BomManagementPage() {
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
}
|
||||
setItemSearchResults(rows);
|
||||
const resolved = rows.map((r: any) => {
|
||||
const out = { ...r };
|
||||
if (out.division) {
|
||||
out.division = out.division.split(",").map((c: string) => {
|
||||
const t = c.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === t)?.label || t;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
}
|
||||
if (out.unit) {
|
||||
out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
setItemSearchResults(resolved);
|
||||
} catch {
|
||||
toast.error("품목 검색에 실패했어요");
|
||||
} finally {
|
||||
@@ -1201,14 +1214,20 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUnit = (code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
const unitLabel = resolveUnit(item.unit);
|
||||
if (itemSearchTarget === "master") {
|
||||
setMasterForm((prev) => ({
|
||||
...prev,
|
||||
item_id: item.id,
|
||||
item_code: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
unit: item.unit || prev.unit,
|
||||
unit: unitLabel || prev.unit,
|
||||
}));
|
||||
setShowItemSearchModal(false);
|
||||
} else {
|
||||
@@ -1220,7 +1239,7 @@ export default function BomManagementPage() {
|
||||
item_number: item.item_number || "",
|
||||
item_name: item.item_name || "",
|
||||
quantity: "1",
|
||||
unit: item.unit || "",
|
||||
unit: unitLabel,
|
||||
process_type: "",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
|
||||
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (res.success) {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type RoutingVersion,
|
||||
} from "@/lib/api/processInfo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
function normalizeDefaultFlag(v: RoutingVersion): boolean {
|
||||
const raw = v.is_default as unknown;
|
||||
@@ -210,6 +211,7 @@ export function ItemRoutingTab() {
|
||||
return;
|
||||
}
|
||||
const list = res.data.map((v) => ({ ...v, is_default: normalizeDefaultFlag(v) }));
|
||||
list.sort((a, b) => (a.is_default === b.is_default ? 0 : a.is_default ? -1 : 1));
|
||||
setVersions(list);
|
||||
const preferred = preferVersionId ? list.find((v) => v.id === preferVersionId) : undefined;
|
||||
const def = list.find((v) => v.is_default);
|
||||
@@ -399,6 +401,38 @@ export function ItemRoutingTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// 선택 버전이 기본인지
|
||||
const selectedVersionIsDefault = useMemo(() => {
|
||||
if (!selectedVersionId) return false;
|
||||
const v = versions.find((v) => v.id === selectedVersionId);
|
||||
return v ? normalizeDefaultFlag(v) : false;
|
||||
}, [selectedVersionId, versions]);
|
||||
|
||||
// 기본 라우팅으로 설정
|
||||
const handleSetDefaultVersion = async () => {
|
||||
if (!selectedVersionId || !selectedItem) return;
|
||||
try {
|
||||
// 기존 기본 해제
|
||||
for (const v of versions) {
|
||||
if (normalizeDefaultFlag(v) && v.id !== selectedVersionId) {
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: v.id },
|
||||
updatedData: { is_default: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
// 선택 버전 기본 설정
|
||||
await apiClient.put(`/table-management/tables/item_routing_version/edit`, {
|
||||
originalData: { id: selectedVersionId },
|
||||
updatedData: { is_default: true },
|
||||
});
|
||||
toast.success("기본 라우팅으로 설정했어요");
|
||||
await loadVersions(selectedItem, selectedVersionId);
|
||||
} catch {
|
||||
toast.error("기본 설정에 실패했어요");
|
||||
}
|
||||
};
|
||||
|
||||
const submitNewVersion = async () => {
|
||||
if (!selectedItem) return;
|
||||
const name = versionName.trim();
|
||||
@@ -639,6 +673,12 @@ export function ItemRoutingTab() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedVersionId && !selectedVersionIsDefault && (
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={handleSetDefaultVersion}>
|
||||
<Star className="h-3.5 w-3.5 fill-warning text-warning" />
|
||||
기본 설정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function JeilGlassOrderPage() {
|
||||
const custs = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({
|
||||
code: c.customer_code,
|
||||
label: `${c.customer_name} (${c.customer_code})`,
|
||||
label: c.customer_name,
|
||||
}));
|
||||
} catch { /* skip */ }
|
||||
// 담당자
|
||||
|
||||
Reference in New Issue
Block a user