refactor: Update item number handling and improve sales order filtering

- Changed item number field type from 'text' to 'numbering' in item info forms for better clarity.
- Enhanced the logic for loading numbering previews, including handling manual input values and category changes.
- Updated sales order page to filter items based on customer pricing rules, ensuring only relevant items are displayed.
- Improved date handling in sales order page to ensure consistent formatting.

These changes aim to enhance the user experience and data integrity across the application.
This commit is contained in:
kjs
2026-04-09 11:28:55 +09:00
parent 3ac1321953
commit 6ef30f4e45
21 changed files with 1604 additions and 172 deletions
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
];
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
@@ -209,6 +209,13 @@ export default function ItemInfoPage() {
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 카테고리 옵션 로드
useEffect(() => {
const loadCategories = async () => {
@@ -288,26 +295,49 @@ export default function ItemInfoPage() {
}, [fetchItems]);
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
setIsNumberingLoading(true);
// 규칙 조회 (캐싱)
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return "";
// preview 호출 (formData + manualInputValue 전달)
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
setNumberingTemplate(generatedCode);
return generatedCode;
} catch { /* 채번 규칙 없으면 무시 */ }
finally {
setIsNumberingLoading(false);
}
return "";
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 채번 컬럼 자동 로드
const code = await loadNumberingPreview();
// 채번 미리보기
const code = await loadNumberingPreview({});
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
@@ -315,6 +345,8 @@ export default function ItemInfoPage() {
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
@@ -325,13 +357,72 @@ export default function ItemInfoPage() {
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
setIsModalOpen(true);
// 복사된 formData 기반으로 preview
const code = await loadNumberingPreview(rest);
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const 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 }));
}
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
if (!numberingTemplate.includes("____")) 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 */ }
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 저장
const handleSave = async () => {
if (!formData.item_name) {
@@ -342,6 +433,7 @@ export default function ItemInfoPage() {
setSaving(true);
try {
if (isEditMode && editId) {
// 수정: item_number는 변경하지 않음
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
@@ -349,8 +441,42 @@ export default function ItemInfoPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = formData.item_number || "";
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;
})()
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setIsModalOpen(false);
@@ -484,7 +610,15 @@ export default function ItemInfoPage() {
/>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog open={isModalOpen} onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) {
setNumberingTemplate("");
setManualInputValue("");
setNumberingRule(null);
numberingRuleIdRef.current = null;
}
}}>
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
@@ -534,6 +668,61 @@ export default function ItemInfoPage() {
placeholder={field.label}
rows={3}
/>
) : field.type === "numbering" ? (
// 채번 세그먼트 UI
isEditMode ? (
<Input
value={formData[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading ? (
<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}
</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>
);
})()
) : (
<Input
value={formData[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
@@ -548,8 +737,7 @@ export default function ItemInfoPage() {
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
disabled={field.disabled && !isEditMode}
placeholder={field.placeholder || field.label}
className="h-9"
/>
)}
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (res.success) {
toast.success("생산계획이 수정되었습니다");
@@ -249,7 +249,7 @@ export default function SalesOrderPage() {
page: 1, size: 500, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
} catch { /* skip */ }
// 사용자 목록
try {
@@ -608,6 +608,24 @@ export default function SalesOrderPage() {
try {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
let customerItemIds: Set<string> | null = null;
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -615,6 +633,12 @@ export default function SalesOrderPage() {
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
// 거래처우선일 때 연결된 품목만 표시
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
@@ -678,7 +702,8 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
for (const p of prices) {
const start = p.start_date || "";
const end = p.end_date || "";
@@ -768,7 +793,8 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
const priceMap: Record<string, string> = {};
for (const p of prices) {
if (p.start_date && p.start_date > today) continue;
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
];
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
@@ -209,6 +209,13 @@ export default function ItemInfoPage() {
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 카테고리 옵션 로드
useEffect(() => {
const loadCategories = async () => {
@@ -288,26 +295,49 @@ export default function ItemInfoPage() {
}, [fetchItems]);
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
setIsNumberingLoading(true);
// 규칙 조회 (캐싱)
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return "";
// preview 호출 (formData + manualInputValue 전달)
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
setNumberingTemplate(generatedCode);
return generatedCode;
} catch { /* 채번 규칙 없으면 무시 */ }
finally {
setIsNumberingLoading(false);
}
return "";
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 채번 컬럼 자동 로드
const code = await loadNumberingPreview();
// 채번 미리보기
const code = await loadNumberingPreview({});
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
@@ -315,6 +345,8 @@ export default function ItemInfoPage() {
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
@@ -325,13 +357,72 @@ export default function ItemInfoPage() {
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
setIsModalOpen(true);
// 복사된 formData 기반으로 preview
const code = await loadNumberingPreview(rest);
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const 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 }));
}
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
if (!numberingTemplate.includes("____")) 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 */ }
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 저장
const handleSave = async () => {
if (!formData.item_name) {
@@ -342,6 +433,7 @@ export default function ItemInfoPage() {
setSaving(true);
try {
if (isEditMode && editId) {
// 수정: item_number는 변경하지 않음
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
@@ -349,8 +441,42 @@ export default function ItemInfoPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = formData.item_number || "";
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;
})()
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setIsModalOpen(false);
@@ -484,7 +610,15 @@ export default function ItemInfoPage() {
/>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog open={isModalOpen} onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) {
setNumberingTemplate("");
setManualInputValue("");
setNumberingRule(null);
numberingRuleIdRef.current = null;
}
}}>
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
@@ -534,6 +668,61 @@ export default function ItemInfoPage() {
placeholder={field.label}
rows={3}
/>
) : field.type === "numbering" ? (
// 채번 세그먼트 UI
isEditMode ? (
<Input
value={formData[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading ? (
<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}
</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>
);
})()
) : (
<Input
value={formData[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
@@ -548,8 +737,7 @@ export default function ItemInfoPage() {
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
disabled={field.disabled && !isEditMode}
placeholder={field.placeholder || field.label}
className="h-9"
/>
)}
@@ -249,7 +249,7 @@ export default function SalesOrderPage() {
page: 1, size: 500, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
} catch { /* skip */ }
// 사용자 목록
try {
@@ -608,6 +608,24 @@ export default function SalesOrderPage() {
try {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
let customerItemIds: Set<string> | null = null;
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -615,6 +633,12 @@ export default function SalesOrderPage() {
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
// 거래처우선일 때 연결된 품목만 표시
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
@@ -678,7 +702,8 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
for (const p of prices) {
const start = p.start_date || "";
const end = p.end_date || "";
@@ -768,7 +793,8 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
const priceMap: Record<string, string> = {};
for (const p of prices) {
if (p.start_date && p.start_date > today) continue;
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
];
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
@@ -209,6 +209,13 @@ export default function ItemInfoPage() {
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 카테고리 옵션 로드
useEffect(() => {
const loadCategories = async () => {
@@ -288,26 +295,49 @@ export default function ItemInfoPage() {
}, [fetchItems]);
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
setIsNumberingLoading(true);
// 규칙 조회 (캐싱)
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return "";
// preview 호출 (formData + manualInputValue 전달)
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
setNumberingTemplate(generatedCode);
return generatedCode;
} catch { /* 채번 규칙 없으면 무시 */ }
finally {
setIsNumberingLoading(false);
}
return "";
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 채번 컬럼 자동 로드
const code = await loadNumberingPreview();
// 채번 미리보기
const code = await loadNumberingPreview({});
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
@@ -315,6 +345,8 @@ export default function ItemInfoPage() {
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
@@ -325,13 +357,72 @@ export default function ItemInfoPage() {
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
setIsModalOpen(true);
// 복사된 formData 기반으로 preview
const code = await loadNumberingPreview(rest);
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const 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 }));
}
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
if (!numberingTemplate.includes("____")) 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 */ }
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 저장
const handleSave = async () => {
if (!formData.item_name) {
@@ -342,6 +433,7 @@ export default function ItemInfoPage() {
setSaving(true);
try {
if (isEditMode && editId) {
// 수정: item_number는 변경하지 않음
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
@@ -349,8 +441,42 @@ export default function ItemInfoPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = formData.item_number || "";
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;
})()
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setIsModalOpen(false);
@@ -484,7 +610,15 @@ export default function ItemInfoPage() {
/>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog open={isModalOpen} onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) {
setNumberingTemplate("");
setManualInputValue("");
setNumberingRule(null);
numberingRuleIdRef.current = null;
}
}}>
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
@@ -534,6 +668,61 @@ export default function ItemInfoPage() {
placeholder={field.label}
rows={3}
/>
) : field.type === "numbering" ? (
// 채번 세그먼트 UI
isEditMode ? (
<Input
value={formData[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading ? (
<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}
</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>
);
})()
) : (
<Input
value={formData[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
@@ -548,8 +737,7 @@ export default function ItemInfoPage() {
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
disabled={field.disabled && !isEditMode}
placeholder={field.placeholder || field.label}
className="h-9"
/>
)}
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (res.success) {
toast.success("생산계획이 수정되었습니다");
@@ -249,7 +249,7 @@ export default function SalesOrderPage() {
page: 1, size: 500, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
} catch { /* skip */ }
// 사용자 목록
try {
@@ -608,6 +608,24 @@ export default function SalesOrderPage() {
try {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
let customerItemIds: Set<string> | null = null;
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -615,6 +633,12 @@ export default function SalesOrderPage() {
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
// 거래처우선일 때 연결된 품목만 표시
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
@@ -678,7 +702,8 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
for (const p of prices) {
const start = p.start_date || "";
const end = p.end_date || "";
@@ -768,7 +793,8 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
const priceMap: Record<string, string> = {};
for (const p of prices) {
if (p.start_date && p.start_date > today) continue;
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
];
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
@@ -209,6 +209,13 @@ export default function ItemInfoPage() {
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 카테고리 옵션 로드
useEffect(() => {
const loadCategories = async () => {
@@ -288,26 +295,49 @@ export default function ItemInfoPage() {
}, [fetchItems]);
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
setIsNumberingLoading(true);
// 규칙 조회 (캐싱)
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return "";
// preview 호출 (formData + manualInputValue 전달)
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
setNumberingTemplate(generatedCode);
return generatedCode;
} catch { /* 채번 규칙 없으면 무시 */ }
finally {
setIsNumberingLoading(false);
}
return "";
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 채번 컬럼 자동 로드
const code = await loadNumberingPreview();
// 채번 미리보기
const code = await loadNumberingPreview({});
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
@@ -315,6 +345,8 @@ export default function ItemInfoPage() {
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
@@ -325,13 +357,72 @@ export default function ItemInfoPage() {
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
setIsModalOpen(true);
// 복사된 formData 기반으로 preview
const code = await loadNumberingPreview(rest);
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const 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 }));
}
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
if (!numberingTemplate.includes("____")) 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 */ }
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 저장
const handleSave = async () => {
if (!formData.item_name) {
@@ -342,6 +433,7 @@ export default function ItemInfoPage() {
setSaving(true);
try {
if (isEditMode && editId) {
// 수정: item_number는 변경하지 않음
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
@@ -349,8 +441,42 @@ export default function ItemInfoPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = formData.item_number || "";
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;
})()
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setIsModalOpen(false);
@@ -484,7 +610,15 @@ export default function ItemInfoPage() {
/>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog open={isModalOpen} onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) {
setNumberingTemplate("");
setManualInputValue("");
setNumberingRule(null);
numberingRuleIdRef.current = null;
}
}}>
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
@@ -534,6 +668,61 @@ export default function ItemInfoPage() {
placeholder={field.label}
rows={3}
/>
) : field.type === "numbering" ? (
// 채번 세그먼트 UI
isEditMode ? (
<Input
value={formData[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading ? (
<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}
</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>
);
})()
) : (
<Input
value={formData[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
@@ -548,8 +737,7 @@ export default function ItemInfoPage() {
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
disabled={field.disabled && !isEditMode}
placeholder={field.placeholder || field.label}
className="h-9"
/>
)}
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (res.success) {
toast.success("생산계획이 수정되었습니다");
@@ -172,7 +172,7 @@ export default function JeilGlassOrderPage() {
const custs = res.data?.data?.data || res.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({
code: c.customer_code,
label: `${c.customer_name} (${c.customer_code})`,
label: c.customer_name,
}));
} catch { /* skip */ }
// 담당자
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
];
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
@@ -209,6 +209,13 @@ export default function ItemInfoPage() {
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 카테고리 옵션 로드
useEffect(() => {
const loadCategories = async () => {
@@ -288,26 +295,49 @@ export default function ItemInfoPage() {
}, [fetchItems]);
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
setIsNumberingLoading(true);
// 규칙 조회 (캐싱)
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return "";
// preview 호출 (formData + manualInputValue 전달)
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
setNumberingTemplate(generatedCode);
return generatedCode;
} catch { /* 채번 규칙 없으면 무시 */ }
finally {
setIsNumberingLoading(false);
}
return "";
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 채번 컬럼 자동 로드
const code = await loadNumberingPreview();
// 채번 미리보기
const code = await loadNumberingPreview({});
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
@@ -315,6 +345,8 @@ export default function ItemInfoPage() {
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
@@ -325,13 +357,72 @@ export default function ItemInfoPage() {
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
setIsModalOpen(true);
// 복사된 formData 기반으로 preview
const code = await loadNumberingPreview(rest);
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const 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 }));
}
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
if (!numberingTemplate.includes("____")) 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 */ }
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 저장
const handleSave = async () => {
if (!formData.item_name) {
@@ -342,6 +433,7 @@ export default function ItemInfoPage() {
setSaving(true);
try {
if (isEditMode && editId) {
// 수정: item_number는 변경하지 않음
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
@@ -349,8 +441,42 @@ export default function ItemInfoPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = formData.item_number || "";
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;
})()
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setIsModalOpen(false);
@@ -484,7 +610,15 @@ export default function ItemInfoPage() {
/>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog open={isModalOpen} onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) {
setNumberingTemplate("");
setManualInputValue("");
setNumberingRule(null);
numberingRuleIdRef.current = null;
}
}}>
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
@@ -534,6 +668,61 @@ export default function ItemInfoPage() {
placeholder={field.label}
rows={3}
/>
) : field.type === "numbering" ? (
// 채번 세그먼트 UI
isEditMode ? (
<Input
value={formData[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading ? (
<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}
</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>
);
})()
) : (
<Input
value={formData[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
@@ -548,8 +737,7 @@ export default function ItemInfoPage() {
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
disabled={field.disabled && !isEditMode}
placeholder={field.placeholder || field.label}
className="h-9"
/>
)}
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (res.success) {
toast.success("생산계획이 수정되었습니다");
@@ -249,7 +249,7 @@ export default function SalesOrderPage() {
page: 1, size: 500, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
} catch { /* skip */ }
// 사용자 목록
try {
@@ -608,6 +608,24 @@ export default function SalesOrderPage() {
try {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
let customerItemIds: Set<string> | null = null;
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -615,6 +633,12 @@ export default function SalesOrderPage() {
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
// 거래처우선일 때 연결된 품목만 표시
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
@@ -678,7 +702,8 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
for (const p of prices) {
const start = p.start_date || "";
const end = p.end_date || "";
@@ -768,7 +793,8 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
const priceMap: Record<string, string> = {};
for (const p of prices) {
if (p.start_date && p.start_date > today) continue;
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
];
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
@@ -209,6 +209,13 @@ export default function ItemInfoPage() {
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 카테고리 옵션 로드
useEffect(() => {
const loadCategories = async () => {
@@ -288,26 +295,49 @@ export default function ItemInfoPage() {
}, [fetchItems]);
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
setIsNumberingLoading(true);
// 규칙 조회 (캐싱)
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return "";
// preview 호출 (formData + manualInputValue 전달)
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
setNumberingTemplate(generatedCode);
return generatedCode;
} catch { /* 채번 규칙 없으면 무시 */ }
finally {
setIsNumberingLoading(false);
}
return "";
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 채번 컬럼 자동 로드
const code = await loadNumberingPreview();
// 채번 미리보기
const code = await loadNumberingPreview({});
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
@@ -315,6 +345,8 @@ export default function ItemInfoPage() {
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
@@ -325,13 +357,72 @@ export default function ItemInfoPage() {
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
setIsModalOpen(true);
// 복사된 formData 기반으로 preview
const code = await loadNumberingPreview(rest);
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const 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 }));
}
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
if (!numberingTemplate.includes("____")) 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 */ }
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 저장
const handleSave = async () => {
if (!formData.item_name) {
@@ -342,6 +433,7 @@ export default function ItemInfoPage() {
setSaving(true);
try {
if (isEditMode && editId) {
// 수정: item_number는 변경하지 않음
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
@@ -349,8 +441,42 @@ export default function ItemInfoPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = formData.item_number || "";
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;
})()
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setIsModalOpen(false);
@@ -484,7 +610,15 @@ export default function ItemInfoPage() {
/>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog open={isModalOpen} onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) {
setNumberingTemplate("");
setManualInputValue("");
setNumberingRule(null);
numberingRuleIdRef.current = null;
}
}}>
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
@@ -534,6 +668,61 @@ export default function ItemInfoPage() {
placeholder={field.label}
rows={3}
/>
) : field.type === "numbering" ? (
// 채번 세그먼트 UI
isEditMode ? (
<Input
value={formData[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading ? (
<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}
</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>
);
})()
) : (
<Input
value={formData[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
@@ -548,8 +737,7 @@ export default function ItemInfoPage() {
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
disabled={field.disabled && !isEditMode}
placeholder={field.placeholder || field.label}
className="h-9"
/>
)}
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (res.success) {
toast.success("생산계획이 수정되었습니다");
@@ -249,7 +249,7 @@ export default function SalesOrderPage() {
page: 1, size: 500, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
} catch { /* skip */ }
// 사용자 목록
try {
@@ -608,6 +608,24 @@ export default function SalesOrderPage() {
try {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
let customerItemIds: Set<string> | null = null;
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -615,6 +633,12 @@ export default function SalesOrderPage() {
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
// 거래처우선일 때 연결된 품목만 표시
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
@@ -678,7 +702,8 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
for (const p of prices) {
const start = p.start_date || "";
const end = p.end_date || "";
@@ -768,7 +793,8 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
const priceMap: Record<string, string> = {};
for (const p of prices) {
if (p.start_date && p.start_date > today) continue;
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -156,7 +156,7 @@ const GRID_COLUMNS = [
];
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
@@ -209,6 +209,13 @@ export default function ItemInfoPage() {
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingTemplate, setNumberingTemplate] = useState<string>("");
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 카테고리 옵션 로드
useEffect(() => {
const loadCategories = async () => {
@@ -288,26 +295,49 @@ export default function ItemInfoPage() {
}, [fetchItems]);
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
setIsNumberingLoading(true);
// 규칙 조회 (캐싱)
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return "";
// preview 호출 (formData + manualInputValue 전달)
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
setNumberingTemplate(generatedCode);
return generatedCode;
} catch { /* 채번 규칙 없으면 무시 */ }
finally {
setIsNumberingLoading(false);
}
return "";
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 채번 컬럼 자동 로드
const code = await loadNumberingPreview();
// 채번 미리보기
const code = await loadNumberingPreview({});
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
@@ -315,6 +345,8 @@ export default function ItemInfoPage() {
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
@@ -325,13 +357,72 @@ export default function ItemInfoPage() {
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setManualInputValue("");
setNumberingTemplate("");
setIsEditMode(false);
setEditId(null);
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
setIsModalOpen(true);
// 복사된 formData 기반으로 preview
const code = await loadNumberingPreview(rest);
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const 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 }));
}
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
if (!numberingTemplate.includes("____")) 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 */ }
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 저장
const handleSave = async () => {
if (!formData.item_name) {
@@ -342,6 +433,7 @@ export default function ItemInfoPage() {
setSaving(true);
try {
if (isEditMode && editId) {
// 수정: item_number는 변경하지 않음
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
@@ -349,8 +441,42 @@ export default function ItemInfoPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = formData.item_number || "";
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;
})()
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setIsModalOpen(false);
@@ -484,7 +610,15 @@ export default function ItemInfoPage() {
/>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog open={isModalOpen} onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) {
setNumberingTemplate("");
setManualInputValue("");
setNumberingRule(null);
numberingRuleIdRef.current = null;
}
}}>
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
@@ -534,6 +668,61 @@ export default function ItemInfoPage() {
placeholder={field.label}
rows={3}
/>
) : field.type === "numbering" ? (
// 채번 세그먼트 UI
isEditMode ? (
<Input
value={formData[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading ? (
<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}
</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>
);
})()
) : (
<Input
value={formData[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
@@ -548,8 +737,7 @@ export default function ItemInfoPage() {
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
disabled={field.disabled && !isEditMode}
placeholder={field.placeholder || field.label}
className="h-9"
/>
)}
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (res.success) {
toast.success("생산계획이 수정되었습니다");
@@ -172,7 +172,7 @@ export default function JeilGlassOrderPage() {
const custs = res.data?.data?.data || res.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({
code: c.customer_code,
label: `${c.customer_name} (${c.customer_code})`,
label: c.customer_name,
}));
} catch { /* skip */ }
// 담당자