refactor: Enhance numbering rule handling and BOM management
- Updated the `NumberingRuleService` to support manual values in the computation of non-sequence values. - Improved the `ItemInfoPage` to parse preview codes into parts, allowing for better handling of manual inputs and dynamic code generation. - Refactored BOM management to streamline the retrieval of category options for both division and unit, enhancing data consistency. - Added utility functions to resolve unit labels from category options, improving the clarity of displayed data. These changes aim to improve the functionality and user experience in managing item information and BOM processes.
This commit is contained in:
@@ -211,11 +211,121 @@ export default function ItemInfoPage() {
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
|
||||
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 () => {
|
||||
@@ -310,7 +420,7 @@ export default function ItemInfoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return "";
|
||||
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`, {
|
||||
@@ -319,26 +429,41 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
setNumberingTemplate(generatedCode);
|
||||
return generatedCode;
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return "";
|
||||
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("");
|
||||
setNumberingTemplate("");
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
@@ -346,7 +471,7 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -358,13 +483,17 @@ export default function ItemInfoPage() {
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const code = await loadNumberingPreview(rest);
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
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 재호출
|
||||
@@ -377,17 +506,9 @@ export default function ItemInfoPage() {
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const code = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (code) {
|
||||
if (code.includes("____")) {
|
||||
setNumberingTemplate(code);
|
||||
const parts = code.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, item_number: code }));
|
||||
}
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
@@ -398,25 +519,13 @@ export default function ItemInfoPage() {
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingTemplate.includes("____")) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const previewRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/preview`,
|
||||
{ formData, manualInputValue: manualInputValue || undefined }
|
||||
);
|
||||
const newCode = previewRes.data?.data?.generatedCode || "";
|
||||
if (newCode) {
|
||||
setNumberingTemplate(newCode);
|
||||
if (newCode.includes("____")) {
|
||||
const parts = newCode.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
@@ -446,13 +555,9 @@ export default function ItemInfoPage() {
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const userInputCode = numberingTemplate.includes("____")
|
||||
? (() => {
|
||||
const parts = numberingTemplate.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
return prefix + manualInputValue + suffix;
|
||||
})()
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual
|
||||
? buildCodeFromParts(numberingParts, manualInputValue)
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
@@ -613,7 +718,7 @@ export default function ItemInfoPage() {
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
@@ -676,46 +781,59 @@ export default function ItemInfoPage() {
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading ? (
|
||||
) : 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>
|
||||
) : numberingTemplate.includes("____") ? (
|
||||
(() => {
|
||||
const tplParts = numberingTemplate.split("____");
|
||||
const prefix = tplParts[0] || "";
|
||||
const suffix = tplParts.slice(1).join("") || "";
|
||||
return (
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{prefix && (
|
||||
<span className="flex h-full items-center rounded-l-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{prefix}
|
||||
) : 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>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: prefix + val + suffix,
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="flex h-full items-center rounded-r-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -211,11 +211,121 @@ export default function ItemInfoPage() {
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
|
||||
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 () => {
|
||||
@@ -310,7 +420,7 @@ export default function ItemInfoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return "";
|
||||
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`, {
|
||||
@@ -319,26 +429,41 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
setNumberingTemplate(generatedCode);
|
||||
return generatedCode;
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return "";
|
||||
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("");
|
||||
setNumberingTemplate("");
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
@@ -346,7 +471,7 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -358,13 +483,17 @@ export default function ItemInfoPage() {
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const code = await loadNumberingPreview(rest);
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
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 재호출
|
||||
@@ -377,17 +506,9 @@ export default function ItemInfoPage() {
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const code = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (code) {
|
||||
if (code.includes("____")) {
|
||||
setNumberingTemplate(code);
|
||||
const parts = code.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, item_number: code }));
|
||||
}
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
@@ -398,25 +519,13 @@ export default function ItemInfoPage() {
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingTemplate.includes("____")) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const previewRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/preview`,
|
||||
{ formData, manualInputValue: manualInputValue || undefined }
|
||||
);
|
||||
const newCode = previewRes.data?.data?.generatedCode || "";
|
||||
if (newCode) {
|
||||
setNumberingTemplate(newCode);
|
||||
if (newCode.includes("____")) {
|
||||
const parts = newCode.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
@@ -446,13 +555,9 @@ export default function ItemInfoPage() {
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const userInputCode = numberingTemplate.includes("____")
|
||||
? (() => {
|
||||
const parts = numberingTemplate.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
return prefix + manualInputValue + suffix;
|
||||
})()
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual
|
||||
? buildCodeFromParts(numberingParts, manualInputValue)
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
@@ -613,7 +718,7 @@ export default function ItemInfoPage() {
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
@@ -676,46 +781,59 @@ export default function ItemInfoPage() {
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading ? (
|
||||
) : 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>
|
||||
) : numberingTemplate.includes("____") ? (
|
||||
(() => {
|
||||
const tplParts = numberingTemplate.split("____");
|
||||
const prefix = tplParts[0] || "";
|
||||
const suffix = tplParts.slice(1).join("") || "";
|
||||
return (
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{prefix && (
|
||||
<span className="flex h-full items-center rounded-l-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{prefix}
|
||||
) : 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>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: prefix + val + suffix,
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="flex h-full items-center rounded-r-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -211,11 +211,121 @@ export default function ItemInfoPage() {
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
|
||||
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 () => {
|
||||
@@ -310,7 +420,7 @@ export default function ItemInfoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return "";
|
||||
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`, {
|
||||
@@ -319,26 +429,41 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
setNumberingTemplate(generatedCode);
|
||||
return generatedCode;
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return "";
|
||||
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("");
|
||||
setNumberingTemplate("");
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
@@ -346,7 +471,7 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -358,13 +483,17 @@ export default function ItemInfoPage() {
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const code = await loadNumberingPreview(rest);
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
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 재호출
|
||||
@@ -377,17 +506,9 @@ export default function ItemInfoPage() {
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const code = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (code) {
|
||||
if (code.includes("____")) {
|
||||
setNumberingTemplate(code);
|
||||
const parts = code.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, item_number: code }));
|
||||
}
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
@@ -398,25 +519,13 @@ export default function ItemInfoPage() {
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingTemplate.includes("____")) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const previewRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/preview`,
|
||||
{ formData, manualInputValue: manualInputValue || undefined }
|
||||
);
|
||||
const newCode = previewRes.data?.data?.generatedCode || "";
|
||||
if (newCode) {
|
||||
setNumberingTemplate(newCode);
|
||||
if (newCode.includes("____")) {
|
||||
const parts = newCode.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
@@ -446,13 +555,9 @@ export default function ItemInfoPage() {
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const userInputCode = numberingTemplate.includes("____")
|
||||
? (() => {
|
||||
const parts = numberingTemplate.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
return prefix + manualInputValue + suffix;
|
||||
})()
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual
|
||||
? buildCodeFromParts(numberingParts, manualInputValue)
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
@@ -613,7 +718,7 @@ export default function ItemInfoPage() {
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
@@ -676,46 +781,59 @@ export default function ItemInfoPage() {
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading ? (
|
||||
) : 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>
|
||||
) : numberingTemplate.includes("____") ? (
|
||||
(() => {
|
||||
const tplParts = numberingTemplate.split("____");
|
||||
const prefix = tplParts[0] || "";
|
||||
const suffix = tplParts.slice(1).join("") || "";
|
||||
return (
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{prefix && (
|
||||
<span className="flex h-full items-center rounded-l-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{prefix}
|
||||
) : 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>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: prefix + val + suffix,
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="flex h-full items-center rounded-r-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -211,11 +211,121 @@ export default function ItemInfoPage() {
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
|
||||
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 () => {
|
||||
@@ -310,7 +420,7 @@ export default function ItemInfoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return "";
|
||||
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`, {
|
||||
@@ -319,26 +429,41 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
setNumberingTemplate(generatedCode);
|
||||
return generatedCode;
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return "";
|
||||
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("");
|
||||
setNumberingTemplate("");
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
@@ -346,7 +471,7 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -358,13 +483,17 @@ export default function ItemInfoPage() {
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const code = await loadNumberingPreview(rest);
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
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 재호출
|
||||
@@ -377,17 +506,9 @@ export default function ItemInfoPage() {
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const code = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (code) {
|
||||
if (code.includes("____")) {
|
||||
setNumberingTemplate(code);
|
||||
const parts = code.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, item_number: code }));
|
||||
}
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
@@ -398,25 +519,13 @@ export default function ItemInfoPage() {
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingTemplate.includes("____")) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const previewRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/preview`,
|
||||
{ formData, manualInputValue: manualInputValue || undefined }
|
||||
);
|
||||
const newCode = previewRes.data?.data?.generatedCode || "";
|
||||
if (newCode) {
|
||||
setNumberingTemplate(newCode);
|
||||
if (newCode.includes("____")) {
|
||||
const parts = newCode.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
@@ -446,13 +555,9 @@ export default function ItemInfoPage() {
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const userInputCode = numberingTemplate.includes("____")
|
||||
? (() => {
|
||||
const parts = numberingTemplate.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
return prefix + manualInputValue + suffix;
|
||||
})()
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual
|
||||
? buildCodeFromParts(numberingParts, manualInputValue)
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
@@ -613,7 +718,7 @@ export default function ItemInfoPage() {
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
@@ -676,46 +781,59 @@ export default function ItemInfoPage() {
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading ? (
|
||||
) : 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>
|
||||
) : numberingTemplate.includes("____") ? (
|
||||
(() => {
|
||||
const tplParts = numberingTemplate.split("____");
|
||||
const prefix = tplParts[0] || "";
|
||||
const suffix = tplParts.slice(1).join("") || "";
|
||||
return (
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{prefix && (
|
||||
<span className="flex h-full items-center rounded-l-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{prefix}
|
||||
) : 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>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: prefix + val + suffix,
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="flex h-full items-center rounded-r-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -211,11 +211,121 @@ export default function ItemInfoPage() {
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
|
||||
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 () => {
|
||||
@@ -310,7 +420,7 @@ export default function ItemInfoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return "";
|
||||
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`, {
|
||||
@@ -319,26 +429,41 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
setNumberingTemplate(generatedCode);
|
||||
return generatedCode;
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return "";
|
||||
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("");
|
||||
setNumberingTemplate("");
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
@@ -346,7 +471,7 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -358,13 +483,17 @@ export default function ItemInfoPage() {
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const code = await loadNumberingPreview(rest);
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
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 재호출
|
||||
@@ -377,17 +506,9 @@ export default function ItemInfoPage() {
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const code = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (code) {
|
||||
if (code.includes("____")) {
|
||||
setNumberingTemplate(code);
|
||||
const parts = code.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, item_number: code }));
|
||||
}
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
@@ -398,25 +519,13 @@ export default function ItemInfoPage() {
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingTemplate.includes("____")) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const previewRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/preview`,
|
||||
{ formData, manualInputValue: manualInputValue || undefined }
|
||||
);
|
||||
const newCode = previewRes.data?.data?.generatedCode || "";
|
||||
if (newCode) {
|
||||
setNumberingTemplate(newCode);
|
||||
if (newCode.includes("____")) {
|
||||
const parts = newCode.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
@@ -446,13 +555,9 @@ export default function ItemInfoPage() {
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const userInputCode = numberingTemplate.includes("____")
|
||||
? (() => {
|
||||
const parts = numberingTemplate.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
return prefix + manualInputValue + suffix;
|
||||
})()
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual
|
||||
? buildCodeFromParts(numberingParts, manualInputValue)
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
@@ -613,7 +718,7 @@ export default function ItemInfoPage() {
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
@@ -676,46 +781,59 @@ export default function ItemInfoPage() {
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading ? (
|
||||
) : 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>
|
||||
) : numberingTemplate.includes("____") ? (
|
||||
(() => {
|
||||
const tplParts = numberingTemplate.split("____");
|
||||
const prefix = tplParts[0] || "";
|
||||
const suffix = tplParts.slice(1).join("") || "";
|
||||
return (
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{prefix && (
|
||||
<span className="flex h-full items-center rounded-l-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{prefix}
|
||||
) : 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>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: prefix + val + suffix,
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="flex h-full items-center rounded-r-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -211,11 +211,121 @@ export default function ItemInfoPage() {
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
|
||||
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 () => {
|
||||
@@ -310,7 +420,7 @@ export default function ItemInfoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return "";
|
||||
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`, {
|
||||
@@ -319,26 +429,41 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
setNumberingTemplate(generatedCode);
|
||||
return generatedCode;
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return "";
|
||||
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("");
|
||||
setNumberingTemplate("");
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
@@ -346,7 +471,7 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -358,13 +483,17 @@ export default function ItemInfoPage() {
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const code = await loadNumberingPreview(rest);
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
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 재호출
|
||||
@@ -377,17 +506,9 @@ export default function ItemInfoPage() {
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const code = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (code) {
|
||||
if (code.includes("____")) {
|
||||
setNumberingTemplate(code);
|
||||
const parts = code.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, item_number: code }));
|
||||
}
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
@@ -398,25 +519,13 @@ export default function ItemInfoPage() {
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingTemplate.includes("____")) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const previewRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/preview`,
|
||||
{ formData, manualInputValue: manualInputValue || undefined }
|
||||
);
|
||||
const newCode = previewRes.data?.data?.generatedCode || "";
|
||||
if (newCode) {
|
||||
setNumberingTemplate(newCode);
|
||||
if (newCode.includes("____")) {
|
||||
const parts = newCode.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
@@ -446,13 +555,9 @@ export default function ItemInfoPage() {
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const userInputCode = numberingTemplate.includes("____")
|
||||
? (() => {
|
||||
const parts = numberingTemplate.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
return prefix + manualInputValue + suffix;
|
||||
})()
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual
|
||||
? buildCodeFromParts(numberingParts, manualInputValue)
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
@@ -613,7 +718,7 @@ export default function ItemInfoPage() {
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
@@ -676,46 +781,59 @@ export default function ItemInfoPage() {
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading ? (
|
||||
) : 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>
|
||||
) : numberingTemplate.includes("____") ? (
|
||||
(() => {
|
||||
const tplParts = numberingTemplate.split("____");
|
||||
const prefix = tplParts[0] || "";
|
||||
const suffix = tplParts.slice(1).join("") || "";
|
||||
return (
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{prefix && (
|
||||
<span className="flex h-full items-center rounded-l-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{prefix}
|
||||
) : 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>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: prefix + val + suffix,
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="flex h-full items-center rounded-r-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -211,11 +211,121 @@ export default function ItemInfoPage() {
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
|
||||
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 () => {
|
||||
@@ -310,7 +420,7 @@ export default function ItemInfoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return "";
|
||||
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`, {
|
||||
@@ -319,26 +429,41 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
setNumberingTemplate(generatedCode);
|
||||
return generatedCode;
|
||||
// 파트별 표시값 추출
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return "";
|
||||
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("");
|
||||
setNumberingTemplate("");
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
@@ -346,7 +471,7 @@ export default function ItemInfoPage() {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
@@ -358,13 +483,17 @@ export default function ItemInfoPage() {
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
|
||||
setFormData(rest);
|
||||
setManualInputValue("");
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 복사된 formData 기반으로 preview
|
||||
const code = await loadNumberingPreview(rest);
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
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 재호출
|
||||
@@ -377,17 +506,9 @@ export default function ItemInfoPage() {
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const code = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (code) {
|
||||
if (code.includes("____")) {
|
||||
setNumberingTemplate(code);
|
||||
const parts = code.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, item_number: code }));
|
||||
}
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
@@ -398,25 +519,13 @@ export default function ItemInfoPage() {
|
||||
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingTemplate.includes("____")) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const previewRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/preview`,
|
||||
{ formData, manualInputValue: manualInputValue || undefined }
|
||||
);
|
||||
const newCode = previewRes.data?.data?.generatedCode || "";
|
||||
if (newCode) {
|
||||
setNumberingTemplate(newCode);
|
||||
if (newCode.includes("____")) {
|
||||
const parts = newCode.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix }));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
@@ -446,13 +555,9 @@ export default function ItemInfoPage() {
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const userInputCode = numberingTemplate.includes("____")
|
||||
? (() => {
|
||||
const parts = numberingTemplate.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.slice(1).join("") || "";
|
||||
return prefix + manualInputValue + suffix;
|
||||
})()
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual
|
||||
? buildCodeFromParts(numberingParts, manualInputValue)
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
@@ -613,7 +718,7 @@ export default function ItemInfoPage() {
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setNumberingTemplate("");
|
||||
setNumberingParts([]);
|
||||
setManualInputValue("");
|
||||
setNumberingRule(null);
|
||||
numberingRuleIdRef.current = null;
|
||||
@@ -676,46 +781,59 @@ export default function ItemInfoPage() {
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading ? (
|
||||
) : 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>
|
||||
) : numberingTemplate.includes("____") ? (
|
||||
(() => {
|
||||
const tplParts = numberingTemplate.split("____");
|
||||
const prefix = tplParts[0] || "";
|
||||
const suffix = tplParts.slice(1).join("") || "";
|
||||
return (
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{prefix && (
|
||||
<span className="flex h-full items-center rounded-l-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{prefix}
|
||||
) : 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>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: prefix + val + suffix,
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="flex h-full items-center rounded-r-[5px] bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
Reference in New Issue
Block a user