123
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
* 공급업체관리와 양방향 연동 (같은 supplier_item_mapping 테이블)
|
||||
*/
|
||||
|
||||
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";
|
||||
@@ -137,7 +137,7 @@ function MultiCategoryCombobox({ options, value, onChange, placeholder }: {
|
||||
}
|
||||
|
||||
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" },
|
||||
@@ -217,6 +217,13 @@ export default function PurchaseItemPage() {
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
|
||||
const [manualInputValue, setManualInputValue] = useState<string>("");
|
||||
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
|
||||
const numberingRuleIdRef = useRef<string | null>(null);
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
@@ -340,38 +347,194 @@ export default function PurchaseItemPage() {
|
||||
|
||||
useEffect(() => { fetchItems(); }, [fetchItems]);
|
||||
|
||||
// 채번 미리보기
|
||||
const loadNumberingPreview = async () => {
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/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 || "";
|
||||
// 프리뷰 코드에서 각 파트별 표시값을 추출
|
||||
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 || "";
|
||||
|
||||
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" };
|
||||
}
|
||||
default: return { known: false, sep, isManual: false, partType: part.partType };
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
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 {
|
||||
let endIdx = remaining.length;
|
||||
if (meta.sep) {
|
||||
if (nextMeta) {
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
const patIdx = remaining.indexOf(meta.sep + nextMeta.value);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
const patIdx = remaining.indexOf(meta.sep + "____");
|
||||
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;
|
||||
};
|
||||
|
||||
// 파트 값으로부터 전체 코드 조합
|
||||
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 loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
|
||||
try {
|
||||
setIsNumberingLoading(true);
|
||||
|
||||
let rule = numberingRule;
|
||||
if (!rule) {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`);
|
||||
rule = ruleRes.data?.data;
|
||||
if (rule) {
|
||||
setNumberingRule(rule);
|
||||
numberingRuleIdRef.current = rule.ruleId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
|
||||
formData: currentFormData || {},
|
||||
manualInputValue: currentManualValue || undefined,
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
return "";
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
};
|
||||
|
||||
// 등록 모달 열기
|
||||
const openRegisterModal = async () => {
|
||||
setFormData({});
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData((prev) => ({ ...prev, item_number: code }));
|
||||
const result = await loadNumberingPreview({});
|
||||
if (result.code) {
|
||||
const hasManual = result.parts.some(p => p.isManual);
|
||||
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
|
||||
setFormData(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const openEditModal = (item: any) => {
|
||||
const raw = rawItems.find((r) => r.id === item.id) || item;
|
||||
setFormData({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 카테고리 변경 시 채번 preview 재호출
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
|
||||
const hasCategoryPart = numberingRule?.parts?.some(
|
||||
(p: any) => p.partType === "category" && p.generationMethod === "auto"
|
||||
);
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
|
||||
|
||||
// 수동 입력값 변경 시 preview 갱신
|
||||
useEffect(() => {
|
||||
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(formData, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [manualInputValue]);
|
||||
|
||||
// 저장 (등록 또는 수정)
|
||||
const handleSave = async () => {
|
||||
if (!formData.item_name) {
|
||||
@@ -388,8 +551,38 @@ export default function PurchaseItemPage() {
|
||||
});
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
// 신규 등록: allocateCode 호출하여 실제 순번 확보
|
||||
let finalItemNumber = formData.item_number || "";
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual && manualInputValue
|
||||
? manualInputValue
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
|
||||
{ formData, userInputCode }
|
||||
);
|
||||
|
||||
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
|
||||
finalItemNumber = allocRes.data.data.generatedCode;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("채번 할당 실패:", err);
|
||||
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { id, created_date, updated_date, ...insertFields } = formData;
|
||||
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { id: crypto.randomUUID(), ...insertFields });
|
||||
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
...insertFields,
|
||||
item_number: finalItemNumber,
|
||||
});
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
@@ -664,25 +857,51 @@ export default function PurchaseItemPage() {
|
||||
if (found) custInfo = found;
|
||||
} catch { /* skip */ }
|
||||
|
||||
const mappingRows = [{
|
||||
_id: `m_existing_${row.id}`,
|
||||
supplier_item_code: row.supplier_item_code || "",
|
||||
supplier_item_name: row.supplier_item_name || "",
|
||||
}].filter((m) => m.supplier_item_code || m.supplier_item_name);
|
||||
// 매핑 전체 조회
|
||||
let mappingRows: any[] = [];
|
||||
try {
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: custKey },
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
mappingRows = allMappings
|
||||
.filter((m: any) => m.supplier_item_code || m.supplier_item_name)
|
||||
.map((m: any) => ({
|
||||
_id: `m_existing_${m.id}`,
|
||||
supplier_item_code: m.supplier_item_code || "",
|
||||
supplier_item_name: m.supplier_item_name || "",
|
||||
}));
|
||||
} catch { /* skip */ }
|
||||
|
||||
const priceRows = [{
|
||||
_id: `p_existing_${row.id}`,
|
||||
start_date: row.start_date || "",
|
||||
end_date: row.end_date || "",
|
||||
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI",
|
||||
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW",
|
||||
base_price: row.base_price ? String(row.base_price) : "",
|
||||
discount_type: row.discount_type || "",
|
||||
discount_value: row.discount_value ? String(row.discount_value) : "",
|
||||
rounding_type: row.rounding_type || "",
|
||||
rounding_unit_value: row.rounding_unit_value || "",
|
||||
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
|
||||
}].filter((p) => p.base_price || p.start_date);
|
||||
// 단가 전체 조회
|
||||
let priceRows: any[] = [];
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: custKey },
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
||||
priceRows = allPriceData.map((p: any) => ({
|
||||
_id: `p_existing_${p.id}`,
|
||||
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
|
||||
end_date: p.end_date ? String(p.end_date).split("T")[0] : "",
|
||||
currency_code: p.currency_code || "CAT_MLAMDKVN_PZJI",
|
||||
base_price_type: p.base_price_type || "CAT_MLAMFGFT_4RZW",
|
||||
base_price: p.base_price ? String(p.base_price) : "",
|
||||
discount_type: p.discount_type || "",
|
||||
discount_value: p.discount_value ? String(p.discount_value) : "",
|
||||
rounding_type: p.rounding_type || "",
|
||||
rounding_unit_value: p.rounding_unit_value || "",
|
||||
calculated_price: p.calculated_price ? String(p.calculated_price) : "",
|
||||
}));
|
||||
} catch { /* skip */ }
|
||||
|
||||
if (priceRows.length === 0) {
|
||||
priceRows.push({
|
||||
@@ -709,47 +928,104 @@ export default function PurchaseItemPage() {
|
||||
const mappingRows = suppMappings[custKey] || [];
|
||||
|
||||
if (isEditingExisting && editSuppData?.id) {
|
||||
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
||||
originalData: { id: editSuppData.id },
|
||||
updatedData: {
|
||||
supplier_item_code: mappingRows[0]?.supplier_item_code || "",
|
||||
supplier_item_name: mappingRows[0]?.supplier_item_name || "",
|
||||
},
|
||||
});
|
||||
|
||||
// 기존 prices 삭제 후 재등록
|
||||
// 매핑: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
|
||||
const keptIds = new Set<string>();
|
||||
let firstMappingId: string | null = null;
|
||||
for (let mi = 0; mi < mappingRows.length; mi++) {
|
||||
const m = mappingRows[mi];
|
||||
if (m._id?.startsWith("m_existing_")) {
|
||||
const realId = m._id.replace("m_existing_", "");
|
||||
keptIds.add(realId);
|
||||
if (mi === 0) firstMappingId = realId;
|
||||
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
||||
originalData: { id: realId },
|
||||
updatedData: {
|
||||
supplier_item_code: m.supplier_item_code || "",
|
||||
supplier_item_name: m.supplier_item_name || "",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const res = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
supplier_id: custKey, item_id: selectedItem.item_number,
|
||||
supplier_item_code: m.supplier_item_code || "",
|
||||
supplier_item_name: m.supplier_item_name || "",
|
||||
});
|
||||
if (mi === 0) firstMappingId = res.data?.data?.id || null;
|
||||
}
|
||||
}
|
||||
// 폼에서 삭제된 매핑 → DB 삭제
|
||||
try {
|
||||
const existingPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
const dbMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "mapping_id", operator: "equals", value: editSuppData.id },
|
||||
{ columnName: "supplier_id", operator: "equals", value: custKey },
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, {
|
||||
data: existing.map((p: any) => ({ id: p.id })),
|
||||
const toDelete = (dbMappings.data?.data?.data || dbMappings.data?.data?.rows || [])
|
||||
.filter((m: any) => !keptIds.has(m.id));
|
||||
if (toDelete.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
|
||||
data: toDelete.map((m: any) => ({ id: m.id })),
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
if (!firstMappingId) firstMappingId = editSuppData.id;
|
||||
|
||||
// 단가: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
|
||||
const keptPriceIds = new Set<string>();
|
||||
const priceRows = (suppPrices[custKey] || []).filter((p) =>
|
||||
p.base_price || p.start_date || p.currency_code || p.base_price_type
|
||||
);
|
||||
for (const price of priceRows) {
|
||||
await apiClient.post(`/table-management/tables/supplier_item_prices/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
mapping_id: editSuppData.id,
|
||||
supplier_id: custKey,
|
||||
item_id: selectedItem.item_number,
|
||||
start_date: price.start_date || null, end_date: price.end_date || null,
|
||||
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
|
||||
base_price: price.base_price ? Number(price.base_price) : null,
|
||||
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
|
||||
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
|
||||
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
|
||||
});
|
||||
if (price._id?.startsWith("p_existing_")) {
|
||||
const realId = price._id.replace("p_existing_", "");
|
||||
keptPriceIds.add(realId);
|
||||
await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, {
|
||||
originalData: { id: realId },
|
||||
updatedData: {
|
||||
mapping_id: firstMappingId || "",
|
||||
start_date: price.start_date || null, end_date: price.end_date || null,
|
||||
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
|
||||
base_price: price.base_price ? Number(price.base_price) : null,
|
||||
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
|
||||
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
|
||||
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/supplier_item_prices/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
mapping_id: firstMappingId || "",
|
||||
supplier_id: custKey, item_id: selectedItem.item_number,
|
||||
start_date: price.start_date || null, end_date: price.end_date || null,
|
||||
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
|
||||
base_price: price.base_price ? Number(price.base_price) : null,
|
||||
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
|
||||
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
|
||||
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 폼에서 삭제된 단가 → DB 삭제
|
||||
try {
|
||||
const dbPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: custKey },
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const toDeletePrices = (dbPrices.data?.data?.data || dbPrices.data?.data?.rows || [])
|
||||
.filter((p: any) => !keptPriceIds.has(p.id));
|
||||
if (toDeletePrices.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, {
|
||||
data: toDeletePrices.map((p: any) => ({ id: p.id })),
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} else {
|
||||
// 신규 등록
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||
@@ -1216,7 +1492,71 @@ export default function PurchaseItemPage() {
|
||||
{field.label}
|
||||
{"required" in field && field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.type === "image" ? (
|
||||
{field.type === "numbering" ? (
|
||||
isEditMode ? (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading && numberingParts.length === 0 ? (
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">생성 중...</span>
|
||||
</div>
|
||||
) : numberingParts.some(p => p.isManual) ? (
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{numberingParts.map((part, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === numberingParts.length - 1;
|
||||
if (part.isManual) {
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
item_number: buildCodeFromParts(numberingParts, val),
|
||||
}));
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
|
||||
/>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<span className={cn(
|
||||
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
|
||||
isFirst && "rounded-l-[5px]",
|
||||
isLast && "rounded-r-[5px]",
|
||||
)}>
|
||||
{part.value}
|
||||
</span>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
placeholder="자동 채번"
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
)
|
||||
) : field.type === "image" ? (
|
||||
<ImageUpload
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||
@@ -1260,8 +1600,7 @@ export default function PurchaseItemPage() {
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={"placeholder" in field ? field.placeholder : ("disabled" in field && field.disabled ? "자동 채번" : field.label)}
|
||||
disabled={"disabled" in field && field.disabled && !isEditMode}
|
||||
placeholder={"placeholder" in field ? field.placeholder : field.label}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 거래처관리와 양방향 연동 (같은 customer_item_mapping 테이블)
|
||||
*/
|
||||
|
||||
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";
|
||||
@@ -157,7 +157,7 @@ const ITEM_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" },
|
||||
@@ -241,6 +241,13 @@ export default function SalesItemPage() {
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
|
||||
const [manualInputValue, setManualInputValue] = useState<string>("");
|
||||
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
|
||||
const numberingRuleIdRef = useRef<string | null>(null);
|
||||
|
||||
// 엑셀
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
|
||||
@@ -673,47 +680,104 @@ export default function SalesItemPage() {
|
||||
const mappingRows = custMappings[custKey] || [];
|
||||
|
||||
if (isEditingExisting && editCustData?.id) {
|
||||
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
||||
originalData: { id: editCustData.id },
|
||||
updatedData: {
|
||||
customer_item_code: mappingRows[0]?.customer_item_code || "",
|
||||
customer_item_name: mappingRows[0]?.customer_item_name || "",
|
||||
},
|
||||
});
|
||||
|
||||
// 기존 prices 삭제 후 재등록
|
||||
// 매핑: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
|
||||
const keptIds = new Set<string>();
|
||||
let firstMappingId: string | null = null;
|
||||
for (let mi = 0; mi < mappingRows.length; mi++) {
|
||||
const m = mappingRows[mi];
|
||||
if (m._id?.startsWith("m_existing_")) {
|
||||
const realId = m._id.replace("m_existing_", "");
|
||||
keptIds.add(realId);
|
||||
if (mi === 0) firstMappingId = realId;
|
||||
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
||||
originalData: { id: realId },
|
||||
updatedData: {
|
||||
customer_item_code: m.customer_item_code || "",
|
||||
customer_item_name: m.customer_item_name || "",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const res = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
customer_id: custKey, item_id: selectedItem.item_number,
|
||||
customer_item_code: m.customer_item_code || "",
|
||||
customer_item_name: m.customer_item_name || "",
|
||||
});
|
||||
if (mi === 0) firstMappingId = res.data?.data?.id || null;
|
||||
}
|
||||
}
|
||||
// 폼에서 삭제된 매핑 → DB 삭제
|
||||
try {
|
||||
const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
|
||||
const dbMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "mapping_id", operator: "equals", value: editCustData.id },
|
||||
{ columnName: "customer_id", operator: "equals", value: custKey },
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
|
||||
data: existing.map((p: any) => ({ id: p.id })),
|
||||
const toDelete = (dbMappings.data?.data?.data || dbMappings.data?.data?.rows || [])
|
||||
.filter((m: any) => !keptIds.has(m.id));
|
||||
if (toDelete.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
|
||||
data: toDelete.map((m: any) => ({ id: m.id })),
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
if (!firstMappingId) firstMappingId = editCustData.id;
|
||||
|
||||
// 단가: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
|
||||
const keptPriceIds = new Set<string>();
|
||||
const priceRows = (custPrices[custKey] || []).filter((p) =>
|
||||
p.base_price || p.start_date || p.currency_code || p.base_price_type
|
||||
);
|
||||
for (const price of priceRows) {
|
||||
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
mapping_id: editCustData.id,
|
||||
customer_id: custKey,
|
||||
item_id: selectedItem.item_number,
|
||||
start_date: price.start_date || null, end_date: price.end_date || null,
|
||||
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
|
||||
base_price: price.base_price ? Number(price.base_price) : null,
|
||||
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
|
||||
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
|
||||
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
|
||||
});
|
||||
if (price._id?.startsWith("p_existing_")) {
|
||||
const realId = price._id.replace("p_existing_", "");
|
||||
keptPriceIds.add(realId);
|
||||
await apiClient.put(`/table-management/tables/customer_item_prices/edit`, {
|
||||
originalData: { id: realId },
|
||||
updatedData: {
|
||||
mapping_id: firstMappingId || "",
|
||||
start_date: price.start_date || null, end_date: price.end_date || null,
|
||||
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
|
||||
base_price: price.base_price ? Number(price.base_price) : null,
|
||||
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
|
||||
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
|
||||
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
mapping_id: firstMappingId || "",
|
||||
customer_id: custKey, item_id: selectedItem.item_number,
|
||||
start_date: price.start_date || null, end_date: price.end_date || null,
|
||||
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
|
||||
base_price: price.base_price ? Number(price.base_price) : null,
|
||||
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
|
||||
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
|
||||
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 폼에서 삭제된 단가 → DB 삭제
|
||||
try {
|
||||
const dbPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: custKey },
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const toDeletePrices = (dbPrices.data?.data?.data || dbPrices.data?.data?.rows || [])
|
||||
.filter((p: any) => !keptPriceIds.has(p.id));
|
||||
if (toDeletePrices.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
|
||||
data: toDeletePrices.map((p: any) => ({ id: p.id })),
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} else {
|
||||
// 신규 등록
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||
@@ -765,27 +829,145 @@ export default function SalesItemPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 채번 미리보기 로드
|
||||
const loadNumberingPreview = async () => {
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/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 || "";
|
||||
// 프리뷰 코드에서 각 파트별 표시값을 추출
|
||||
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 || "";
|
||||
|
||||
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" };
|
||||
}
|
||||
default: return { known: false, sep, isManual: false, partType: part.partType };
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
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 {
|
||||
let endIdx = remaining.length;
|
||||
if (meta.sep) {
|
||||
if (nextMeta) {
|
||||
if (nextMeta.known && nextMeta.value) {
|
||||
const patIdx = remaining.indexOf(meta.sep + nextMeta.value);
|
||||
if (patIdx >= 0) endIdx = patIdx;
|
||||
} else if (nextMeta.isManual) {
|
||||
const patIdx = remaining.indexOf(meta.sep + "____");
|
||||
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;
|
||||
};
|
||||
|
||||
// 파트 값으로부터 전체 코드 조합
|
||||
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 loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
|
||||
try {
|
||||
setIsNumberingLoading(true);
|
||||
|
||||
let rule = numberingRule;
|
||||
if (!rule) {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`);
|
||||
rule = ruleRes.data?.data;
|
||||
if (rule) {
|
||||
setNumberingRule(rule);
|
||||
numberingRuleIdRef.current = rule.ruleId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
|
||||
formData: currentFormData || {},
|
||||
manualInputValue: currentManualValue || undefined,
|
||||
});
|
||||
|
||||
const generatedCode = previewRes.data?.data?.generatedCode || "";
|
||||
const parts = parsePreviewIntoParts(generatedCode, rule);
|
||||
setNumberingParts(parts);
|
||||
return { code: generatedCode, parts };
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
return "";
|
||||
finally {
|
||||
setIsNumberingLoading(false);
|
||||
}
|
||||
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
|
||||
};
|
||||
|
||||
// 품목 등록 모달 열기
|
||||
const openRegisterModal = async () => {
|
||||
setEditItemForm({});
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setEditItemOpen(true);
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setEditItemForm(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;
|
||||
setEditItemForm(prev => ({ ...prev, item_number: displayCode }));
|
||||
}
|
||||
};
|
||||
|
||||
// 품목 수정 모달 열기
|
||||
@@ -794,11 +976,49 @@ export default function SalesItemPage() {
|
||||
if (!target) return;
|
||||
const raw = rawItems.find((r) => r.id === target.id) || target;
|
||||
setEditItemForm({ ...raw });
|
||||
setManualInputValue("");
|
||||
setNumberingParts([]);
|
||||
setIsEditMode(true);
|
||||
setEditId(target.id);
|
||||
setEditItemOpen(true);
|
||||
};
|
||||
|
||||
// 카테고리 변경 시 채번 preview 재호출
|
||||
useEffect(() => {
|
||||
if (isEditMode || !editItemOpen || !numberingRuleIdRef.current) return;
|
||||
|
||||
const hasCategoryPart = numberingRule?.parts?.some(
|
||||
(p: any) => p.partType === "category" && p.generationMethod === "auto"
|
||||
);
|
||||
if (!hasCategoryPart) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(editItemForm, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setEditItemForm(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...CATEGORY_COLUMNS_FOR_MODAL.map(col => editItemForm[col])]);
|
||||
|
||||
// 수동 입력값 변경 시 preview 갱신
|
||||
useEffect(() => {
|
||||
if (isEditMode || !editItemOpen || !numberingRuleIdRef.current) return;
|
||||
if (!numberingParts.some(p => p.isManual)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const result = await loadNumberingPreview(editItemForm, manualInputValue);
|
||||
if (result.parts.length > 0) {
|
||||
setEditItemForm(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [manualInputValue]);
|
||||
|
||||
// 품목 저장 (등록 + 수정 통합)
|
||||
const handleEditSave = async () => {
|
||||
if (!editItemForm.item_name) {
|
||||
@@ -815,8 +1035,38 @@ export default function SalesItemPage() {
|
||||
});
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
// 신규 등록: allocateCode 호출하여 실제 순번 확보
|
||||
let finalItemNumber = editItemForm.item_number || "";
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
try {
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual && manualInputValue
|
||||
? manualInputValue
|
||||
: undefined;
|
||||
|
||||
const allocRes = await apiClient.post(
|
||||
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
|
||||
{ formData: editItemForm, 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 } = editItemForm;
|
||||
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { id: crypto.randomUUID(), ...insertFields });
|
||||
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
...insertFields,
|
||||
item_number: finalItemNumber,
|
||||
});
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setEditItemOpen(false);
|
||||
@@ -1238,7 +1488,71 @@ export default function SalesItemPage() {
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.type === "image" ? (
|
||||
{field.type === "numbering" ? (
|
||||
isEditMode ? (
|
||||
<Input
|
||||
value={editItemForm[field.key] || ""}
|
||||
disabled
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
) : isNumberingLoading && numberingParts.length === 0 ? (
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">생성 중...</span>
|
||||
</div>
|
||||
) : numberingParts.some(p => p.isManual) ? (
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{numberingParts.map((part, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === numberingParts.length - 1;
|
||||
if (part.isManual) {
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setManualInputValue(val);
|
||||
setEditItemForm(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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<span className={cn(
|
||||
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
|
||||
isFirst && "rounded-l-[5px]",
|
||||
isLast && "rounded-r-[5px]",
|
||||
)}>
|
||||
{part.value}
|
||||
</span>
|
||||
{part.separator && !isLast && (
|
||||
<span className="text-muted-foreground text-sm">{part.separator}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={editItemForm[field.key] || ""}
|
||||
disabled
|
||||
placeholder="자동 채번"
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
)
|
||||
) : field.type === "image" ? (
|
||||
<ImageUpload
|
||||
value={editItemForm[field.key] || ""}
|
||||
onChange={(v) => setEditItemForm((prev) => ({ ...prev, [field.key]: v }))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, Suspense, useEffect, useCallback, useRef } from "react";
|
||||
import React, { useState, Suspense, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -262,6 +263,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
return false;
|
||||
});
|
||||
const [hasPopMenus, setHasPopMenus] = useState(false);
|
||||
const [hoveredCollapsedMenu, setHoveredCollapsedMenu] = useState<string | null>(null);
|
||||
|
||||
const toggleSidebarCollapse = () => {
|
||||
setSidebarCollapsed((prev) => {
|
||||
@@ -623,22 +625,73 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
};
|
||||
|
||||
// 축소 상태 메뉴 렌더링 (아이콘만, hover 시 오버레이 메뉴로 조작)
|
||||
const collapsedMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCollapsedMouseEnter = useCallback((menuId: string) => {
|
||||
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
|
||||
setHoveredCollapsedMenu(menuId);
|
||||
}, []);
|
||||
|
||||
const handleCollapsedMouseLeave = useCallback(() => {
|
||||
hoverTimeoutRef.current = setTimeout(() => setHoveredCollapsedMenu(null), 150);
|
||||
}, []);
|
||||
|
||||
const renderCollapsedMenu = (menu: any) => {
|
||||
const isActive = isMenuActive(menu);
|
||||
const hasActiveChild = menu.hasChildren && menu.children?.some((child: any) => isMenuActive(child));
|
||||
const isHovered = hoveredCollapsedMenu === menu.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={menu.id}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg transition-colors [&_svg]:h-5 [&_svg]:w-5 ${
|
||||
isActive || hasActiveChild
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleMenuClick(menu)}
|
||||
ref={(el) => { collapsedMenuRefs.current[menu.id] = el; }}
|
||||
onMouseEnter={() => handleCollapsedMouseEnter(menu.id)}
|
||||
onMouseLeave={handleCollapsedMouseLeave}
|
||||
>
|
||||
{menu.icon}
|
||||
</button>
|
||||
<button
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg transition-colors [&_svg]:h-5 [&_svg]:w-5 ${
|
||||
isActive || hasActiveChild
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleMenuClick(menu)}
|
||||
>
|
||||
{menu.icon}
|
||||
</button>
|
||||
|
||||
{isHovered && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] min-w-[180px] rounded-lg border border-border bg-popover p-1.5 shadow-lg"
|
||||
style={{
|
||||
top: collapsedMenuRefs.current[menu.id]?.getBoundingClientRect().top ?? 0,
|
||||
left: (collapsedMenuRefs.current[menu.id]?.getBoundingClientRect().right ?? 0) + 4,
|
||||
}}
|
||||
onMouseEnter={() => handleCollapsedMouseEnter(menu.id)}
|
||||
onMouseLeave={handleCollapsedMouseLeave}
|
||||
>
|
||||
<div className="mb-1 rounded-md px-2.5 py-1.5 text-xs font-semibold text-foreground">
|
||||
{menu.name}
|
||||
</div>
|
||||
{menu.hasChildren && menu.children?.map((child: any) => (
|
||||
<button
|
||||
key={child.id}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground ${
|
||||
isMenuActive(child) ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground"
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleMenuClick(child);
|
||||
setHoveredCollapsedMenu(null);
|
||||
}}
|
||||
>
|
||||
{child.icon}
|
||||
<span className="truncate">{child.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -794,8 +847,69 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 확장 상태 전용: 관리 회사, 모드 전환 버튼 */}
|
||||
{(!isMobile && sidebarCollapsed) ? null : (
|
||||
{/* 관리 회사, 모드 전환 버튼 */}
|
||||
{(!isMobile && sidebarCollapsed) ? (
|
||||
/* 축소 상태: 관리자 아이콘 + hover 팝오버 */
|
||||
((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
||||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
|
||||
(user as ExtendedUserInfo)?.userType === "admin") ? (
|
||||
<div
|
||||
className="flex flex-col items-center gap-1 border-b border-border py-2"
|
||||
ref={(el) => { collapsedMenuRefs.current["__admin__"] = el; }}
|
||||
onMouseEnter={() => handleCollapsedMouseEnter("__admin__")}
|
||||
onMouseLeave={handleCollapsedMouseLeave}
|
||||
>
|
||||
<button
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg transition-colors ${
|
||||
isAdminMode
|
||||
? "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400"
|
||||
: "text-primary bg-primary/10"
|
||||
}`}
|
||||
onClick={handleModeSwitch}
|
||||
>
|
||||
{isAdminMode ? <UserCheck className="h-5 w-5" /> : <Shield className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{hoveredCollapsedMenu === "__admin__" && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] min-w-[200px] rounded-lg border border-border bg-popover p-2 shadow-lg"
|
||||
style={{
|
||||
top: collapsedMenuRefs.current["__admin__"]?.getBoundingClientRect().top ?? 0,
|
||||
left: (collapsedMenuRefs.current["__admin__"]?.getBoundingClientRect().right ?? 0) + 4,
|
||||
}}
|
||||
onMouseEnter={() => handleCollapsedMouseEnter("__admin__")}
|
||||
onMouseLeave={handleCollapsedMouseLeave}
|
||||
>
|
||||
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
|
||||
<div className="mb-2 rounded-md bg-muted/50 px-2.5 py-2">
|
||||
<p className="text-muted-foreground text-[10px]">현재 관리 회사</p>
|
||||
<p className="truncate text-sm font-semibold">{currentCompanyName || "로딩 중..."}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleModeSwitch}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:cursor-pointer ${
|
||||
isAdminMode
|
||||
? "border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400"
|
||||
: "border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 border"
|
||||
}`}
|
||||
>
|
||||
{isAdminMode ? <><UserCheck className="h-4 w-4" />사용자 메뉴로 전환</> : <><Shield className="h-4 w-4" />관리자 메뉴로 전환</>}
|
||||
</Button>
|
||||
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
|
||||
<Button
|
||||
onClick={() => { setShowCompanySwitcher(true); setHoveredCollapsedMenu(null); }}
|
||||
className="border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 mt-1.5 flex w-full items-center justify-center gap-2 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors hover:cursor-pointer"
|
||||
>
|
||||
<Building2 className="h-4 w-4" />회사 선택
|
||||
</Button>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
|
||||
<div className="border-border bg-muted/50 mx-3 mt-3 rounded-md border p-3">
|
||||
|
||||
Reference in New Issue
Block a user