Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
kjs
2026-04-10 15:59:39 +09:00
4 changed files with 200 additions and 156 deletions
@@ -616,6 +616,7 @@ export default function PurchaseItemPage() {
page: 1, size: 500, page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
}); });
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
@@ -866,6 +867,7 @@ export default function PurchaseItemPage() {
{ columnName: "supplier_id", operator: "equals", value: custKey }, { columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, { columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true, ]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
}); });
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings mappingRows = allMappings
@@ -887,7 +889,8 @@ export default function PurchaseItemPage() {
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, { columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true, ]}, autoFilter: true,
}); });
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({ priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`, _id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "", start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -928,104 +931,104 @@ export default function PurchaseItemPage() {
const mappingRows = suppMappings[custKey] || []; const mappingRows = suppMappings[custKey] || [];
if (isEditingExisting && editSuppData?.id) { if (isEditingExisting && editSuppData?.id) {
// 매핑: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE // 기존 매핑 조회
const keptIds = new Set<string>(); let existingMaps: any[] = [];
let firstMappingId: string | null = null; try {
const existingMappings = 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,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
// 매핑 upsert: 인덱스 기반
const usedExistingIds = new Set<string>();
let firstMappingId: string | null = editSuppData.id;
for (let mi = 0; mi < mappingRows.length; mi++) { for (let mi = 0; mi < mappingRows.length; mi++) {
const m = mappingRows[mi]; const existMap = existingMaps[mi];
if (m._id?.startsWith("m_existing_")) { if (existMap) {
const realId = m._id.replace("m_existing_", "");
keptIds.add(realId);
if (mi === 0) firstMappingId = realId;
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: realId }, originalData: { id: existMap.id },
updatedData: { updatedData: {
supplier_item_code: m.supplier_item_code || "", supplier_item_code: mappingRows[mi].supplier_item_code || "",
supplier_item_name: m.supplier_item_name || "", supplier_item_name: mappingRows[mi].supplier_item_name || "",
}, },
}); });
usedExistingIds.add(existMap.id);
if (mi === 0) firstMappingId = existMap.id;
} else { } else {
const res = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { const mRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: crypto.randomUUID(), id: crypto.randomUUID(),
supplier_id: custKey, item_id: selectedItem.item_number, supplier_id: custKey, item_id: selectedItem.item_number,
supplier_item_code: m.supplier_item_code || "", supplier_item_code: mappingRows[mi].supplier_item_code || "",
supplier_item_name: m.supplier_item_name || "", supplier_item_name: mappingRows[mi].supplier_item_name || "",
}); });
if (mi === 0) firstMappingId = res.data?.data?.id || null; if (mi === 0 && !firstMappingId) firstMappingId = mRes.data?.data?.id || null;
} }
} }
// 폼에서 삭제된 매핑 → DB 삭제 // 초과분 delete
const toDeleteMaps = existingMaps.filter((m) => !usedExistingIds.has(m.id));
if (toDeleteMaps.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: toDeleteMaps.map((m: any) => ({ id: m.id })),
});
}
// 기존 단가 조회
let existingPriceRows: any[] = [];
try { try {
const dbMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { const existingPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 100, page: 1, size: 100,
dataFilter: { enabled: true, filters: [ dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey }, { columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number }, { columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true, ]}, autoFilter: true,
}); });
const toDelete = (dbMappings.data?.data?.data || dbMappings.data?.data?.rows || []) existingPriceRows = existingPrices.data?.data?.data || existingPrices.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 */ } } catch { /* skip */ }
if (!firstMappingId) firstMappingId = editSuppData.id; // 단가 upsert: 인덱스 기반
// 단가: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
const keptPriceIds = new Set<string>();
const priceRows = (suppPrices[custKey] || []).filter((p) => const priceRows = (suppPrices[custKey] || []).filter((p) =>
p.base_price || p.start_date || p.currency_code || p.base_price_type p.base_price || p.start_date || p.currency_code || p.base_price_type
); );
for (const price of priceRows) { const usedPriceIds = new Set<string>();
if (price._id?.startsWith("p_existing_")) { for (let pi = 0; pi < priceRows.length; pi++) {
const realId = price._id.replace("p_existing_", ""); const price = priceRows[pi];
keptPriceIds.add(realId); const priceData = {
mapping_id: firstMappingId || 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,
unit_price: price.calculated_price ? Number(price.calculated_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,
};
const existPrice = existingPriceRows[pi];
if (existPrice) {
await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, { await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, {
originalData: { id: realId }, originalData: { id: existPrice.id },
updatedData: { updatedData: priceData,
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,
},
}); });
usedPriceIds.add(existPrice.id);
} else { } else {
await apiClient.post(`/table-management/tables/supplier_item_prices/add`, { await apiClient.post(`/table-management/tables/supplier_item_prices/add`, {
id: crypto.randomUUID(), id: crypto.randomUUID(), ...priceData,
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 삭제 // 초과분 delete
try { const toDeletePrices = existingPriceRows.filter((p) => !usedPriceIds.has(p.id));
const dbPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { if (toDeletePrices.length > 0) {
page: 1, size: 100, await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, {
dataFilter: { enabled: true, filters: [ data: toDeletePrices.map((p: any) => ({ id: p.id })),
{ 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 { } else {
// 신규 등록 // 신규 등록
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
@@ -1055,6 +1058,7 @@ export default function PurchaseItemPage() {
start_date: price.start_date || null, end_date: price.end_date || null, 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, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null, base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_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, 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, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
@@ -1719,7 +1723,7 @@ export default function PurchaseItemPage() {
{/* ── 공급업체 상세 입력/수정 모달 ── */} {/* ── 공급업체 상세 입력/수정 모달 ── */}
<Dialog open={suppDetailOpen} onOpenChange={setSuppDetailOpen}> <Dialog open={suppDetailOpen} onOpenChange={setSuppDetailOpen}>
<DialogContent className="max-w-[1100px] max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-[1100px] h-[85vh] flex flex-col overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{editSuppData ? "수정" : "입력"} {selectedItem?.item_name || ""} {editSuppData ? "수정" : "입력"} {selectedItem?.item_name || ""}
@@ -1729,7 +1733,7 @@ export default function PurchaseItemPage() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-6 py-2"> <div className="space-y-6 py-2 flex-1 overflow-y-auto">
{selectedSuppsForDetail.map((cust, idx) => { {selectedSuppsForDetail.map((cust, idx) => {
const custKey = cust.supplier_code || cust.id; const custKey = cust.supplier_code || cust.id;
const mappingRows = suppMappings[custKey] || []; const mappingRows = suppMappings[custKey] || [];
@@ -264,6 +264,7 @@ export default function SalesItemPage() {
calculated_price: string; calculated_price: string;
}>>>({}); }>>>({});
const [editCustData, setEditCustData] = useState<any>(null); const [editCustData, setEditCustData] = useState<any>(null);
const [collapsedPriceCards, setCollapsedPriceCards] = useState<Set<string>>(new Set());
// 카테고리 로드 // 카테고리 로드
@@ -368,6 +369,7 @@ export default function SalesItemPage() {
page: 1, size: 500, page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
}); });
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
@@ -618,6 +620,7 @@ export default function SalesItemPage() {
{ columnName: "customer_id", operator: "equals", value: custKey }, { columnName: "customer_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, { columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true, ]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
}); });
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings mappingRows = allMappings
@@ -639,7 +642,8 @@ export default function SalesItemPage() {
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, { columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true, ]}, autoFilter: true,
}); });
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({ priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`, _id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "", start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -680,104 +684,104 @@ export default function SalesItemPage() {
const mappingRows = custMappings[custKey] || []; const mappingRows = custMappings[custKey] || [];
if (isEditingExisting && editCustData?.id) { if (isEditingExisting && editCustData?.id) {
// 매핑: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE // 기존 매핑 조회
const keptIds = new Set<string>(); let existingMaps: any[] = [];
let firstMappingId: string | null = null; try {
const existingMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/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,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
// 매핑 upsert: 인덱스 기반
const usedExistingIds = new Set<string>();
let firstMappingId: string | null = editCustData.id;
for (let mi = 0; mi < mappingRows.length; mi++) { for (let mi = 0; mi < mappingRows.length; mi++) {
const m = mappingRows[mi]; const existMap = existingMaps[mi];
if (m._id?.startsWith("m_existing_")) { if (existMap) {
const realId = m._id.replace("m_existing_", "");
keptIds.add(realId);
if (mi === 0) firstMappingId = realId;
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: realId }, originalData: { id: existMap.id },
updatedData: { updatedData: {
customer_item_code: m.customer_item_code || "", customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: m.customer_item_name || "", customer_item_name: mappingRows[mi].customer_item_name || "",
}, },
}); });
usedExistingIds.add(existMap.id);
if (mi === 0) firstMappingId = existMap.id;
} else { } else {
const res = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { const mRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: crypto.randomUUID(), id: crypto.randomUUID(),
customer_id: custKey, item_id: selectedItem.item_number, customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: m.customer_item_code || "", customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: m.customer_item_name || "", customer_item_name: mappingRows[mi].customer_item_name || "",
}); });
if (mi === 0) firstMappingId = res.data?.data?.id || null; if (mi === 0 && !firstMappingId) firstMappingId = mRes.data?.data?.id || null;
} }
} }
// 폼에서 삭제된 매핑 → DB 삭제 // 초과분 delete
const toDeleteMaps = existingMaps.filter((m) => !usedExistingIds.has(m.id));
if (toDeleteMaps.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: toDeleteMaps.map((m: any) => ({ id: m.id })),
});
}
// 기존 단가 조회
let existingPriceRows: any[] = [];
try { try {
const dbMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 100, page: 1, size: 100,
dataFilter: { enabled: true, filters: [ dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: custKey }, { columnName: "customer_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number }, { columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true, ]}, autoFilter: true,
}); });
const toDelete = (dbMappings.data?.data?.data || dbMappings.data?.data?.rows || []) existingPriceRows = existingPrices.data?.data?.data || existingPrices.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 */ } } catch { /* skip */ }
if (!firstMappingId) firstMappingId = editCustData.id; // 단가 upsert: 인덱스 기반
// 단가: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
const keptPriceIds = new Set<string>();
const priceRows = (custPrices[custKey] || []).filter((p) => const priceRows = (custPrices[custKey] || []).filter((p) =>
p.base_price || p.start_date || p.currency_code || p.base_price_type p.base_price || p.start_date || p.currency_code || p.base_price_type
); );
for (const price of priceRows) { const usedPriceIds = new Set<string>();
if (price._id?.startsWith("p_existing_")) { for (let pi = 0; pi < priceRows.length; pi++) {
const realId = price._id.replace("p_existing_", ""); const price = priceRows[pi];
keptPriceIds.add(realId); const priceData = {
mapping_id: firstMappingId || 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,
unit_price: price.calculated_price ? Number(price.calculated_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,
};
const existPrice = existingPriceRows[pi];
if (existPrice) {
await apiClient.put(`/table-management/tables/customer_item_prices/edit`, { await apiClient.put(`/table-management/tables/customer_item_prices/edit`, {
originalData: { id: realId }, originalData: { id: existPrice.id },
updatedData: { updatedData: priceData,
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,
},
}); });
usedPriceIds.add(existPrice.id);
} else { } else {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, { await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
id: crypto.randomUUID(), id: crypto.randomUUID(), ...priceData,
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 삭제 // 초과분 delete
try { const toDeletePrices = existingPriceRows.filter((p) => !usedPriceIds.has(p.id));
const dbPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { if (toDeletePrices.length > 0) {
page: 1, size: 100, await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
dataFilter: { enabled: true, filters: [ data: toDeletePrices.map((p: any) => ({ id: p.id })),
{ 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 { } else {
// 신규 등록 // 신규 등록
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
@@ -807,6 +811,7 @@ export default function SalesItemPage() {
start_date: price.start_date || null, end_date: price.end_date || null, 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, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null, base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_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, 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, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
@@ -823,7 +828,10 @@ export default function SalesItemPage() {
setSelectedItemId(null); setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50); setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) { } catch (err: any) {
toast.error(err.response?.data?.message || "저장 실패했습니다."); console.error("거래처 상세 저장 실패:", err.response?.data);
const detail = err.response?.data?.error?.details;
const msg = err.response?.data?.message || "저장에 실패했습니다.";
toast.error(detail ? `${msg} (${typeof detail === "string" ? detail : JSON.stringify(detail)})` : msg);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -1716,7 +1724,7 @@ export default function SalesItemPage() {
{/* ── 거래처 상세 입력/수정 모달 ── */} {/* ── 거래처 상세 입력/수정 모달 ── */}
<Dialog open={custDetailOpen} onOpenChange={setCustDetailOpen}> <Dialog open={custDetailOpen} onOpenChange={setCustDetailOpen}>
<DialogContent className="max-w-[1100px] max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-[1100px] h-[85vh] flex flex-col overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{editCustData ? "수정" : "입력"} {selectedItem?.item_name || ""} {editCustData ? "수정" : "입력"} {selectedItem?.item_name || ""}
@@ -1726,7 +1734,7 @@ export default function SalesItemPage() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-6 py-2"> <div className="space-y-6 py-2 flex-1 overflow-y-auto">
{selectedCustsForDetail.map((cust, idx) => { {selectedCustsForDetail.map((cust, idx) => {
const custKey = cust.customer_code || cust.id; const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || []; const mappingRows = custMappings[custKey] || [];
@@ -1742,17 +1750,17 @@ export default function SalesItemPage() {
</span> </span>
</div> </div>
<div className="flex gap-4 p-4 bg-card"> <div className="flex gap-4 p-4 bg-card items-stretch">
{/* 좌: 거래처 품번/품명 */} {/* 좌: 거래처 품번/품명 */}
<div className="flex-1 border rounded-lg p-4 bg-muted/50"> <div className="flex-1 border rounded-lg p-4 bg-muted/50 flex flex-col">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-sm font-semibold text-foreground"> / </span> <span className="text-sm font-semibold text-foreground"> / </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}> <Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}>
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
</Button> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2 min-h-[200px] max-h-[350px] overflow-y-auto flex-1">
{mappingRows.length === 0 ? ( {mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div> <div className="text-xs text-muted-foreground py-2"> </div>
) : ( ) : (
@@ -1792,35 +1800,61 @@ export default function SalesItemPage() {
</div> </div>
{/* 우: 기간별 단가 */} {/* 우: 기간별 단가 */}
<div className="flex-1 border rounded-lg p-4 bg-muted/30"> <div className="flex-1 border rounded-lg p-4 bg-muted/30 flex flex-col">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-sm font-semibold text-foreground"> </span> <span className="text-sm font-semibold text-foreground"> </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}> <Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}>
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
</Button> </Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-3 flex-1 overflow-y-auto max-h-[350px]">
{prices.map((price, pIdx) => ( {prices.map((price, pIdx) => (
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2"> <div key={price._id} className="border rounded-lg bg-background overflow-hidden">
<div className="flex items-center justify-between"> <div
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span> className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => setCollapsedPriceCards((prev) => {
const next = new Set(prev);
if (next.has(price._id)) next.delete(price._id); else next.add(price._id);
return next;
})}
>
<div className="flex items-center gap-2">
{collapsedPriceCards.has(price._id)
? <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
}
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span>
{collapsedPriceCards.has(price._id) && price.calculated_price && (
<span className="text-xs text-muted-foreground ml-2">
{price.start_date || "—"} ~ {price.end_date || "—"} · <span className="font-mono font-bold text-foreground">{Number(price.calculated_price).toLocaleString()}</span> {priceCategoryOptions["currency_code"]?.find((o) => o.code === price.currency_code)?.label || ""}
</span>
)}
</div>
{prices.length > 1 && ( {prices.length > 1 && (
<Button <Button
variant="ghost" size="sm" variant="ghost" size="sm"
className="h-6 w-6 p-0 text-destructive" className="h-6 w-6 p-0 text-destructive"
onClick={() => removePriceRow(custKey, price._id)} onClick={(e) => { e.stopPropagation(); removePriceRow(custKey, price._id); }}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
)} )}
</div> </div>
{!collapsedPriceCards.has(price._id) && <div className="px-3 pb-3 space-y-2">
{/* 기간 + 통화 */} {/* 기간 + 통화 */}
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Input <Input
type="date" type="date"
value={price.start_date} value={price.start_date}
onChange={(e) => updatePriceRow(custKey, price._id, "start_date", e.target.value)} onChange={(e) => {
const v = e.target.value;
updatePriceRow(custKey, price._id, "start_date", v);
if (price.end_date && v > price.end_date) {
updatePriceRow(custKey, price._id, "end_date", v);
}
}}
max={price.end_date || undefined}
className="h-8 text-xs flex-1" className="h-8 text-xs flex-1"
/> />
<span className="text-xs text-muted-foreground">~</span> <span className="text-xs text-muted-foreground">~</span>
@@ -1828,6 +1862,7 @@ export default function SalesItemPage() {
type="date" type="date"
value={price.end_date} value={price.end_date}
onChange={(e) => updatePriceRow(custKey, price._id, "end_date", e.target.value)} onChange={(e) => updatePriceRow(custKey, price._id, "end_date", e.target.value)}
min={price.start_date || undefined}
className="h-8 text-xs flex-1" className="h-8 text-xs flex-1"
/> />
<div className="w-[80px]"> <div className="w-[80px]">
@@ -1922,6 +1957,7 @@ export default function SalesItemPage() {
{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"} {price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}
</span> </span>
</div> </div>
</div>}
</div> </div>
))} ))}
</div> </div>
+5 -1
View File
@@ -999,7 +999,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div> </div>
{/* 테마 토글 */} {/* 테마 토글 */}
{(!isMobile && sidebarCollapsed) ? null : ( {(!isMobile && sidebarCollapsed) ? (
<div className="flex items-center justify-center border-t border-border py-2">
<ThemeToggle collapsed />
</div>
) : (
<div className="border-border border-t px-3 py-1"> <div className="border-border border-t px-3 py-1">
<ThemeToggle /> <ThemeToggle />
</div> </div>
+2 -2
View File
@@ -32,10 +32,10 @@ export function ThemeToggle({ collapsed = false }: ThemeToggleProps) {
variant="ghost" variant="ghost"
size={collapsed ? "icon" : "default"} size={collapsed ? "icon" : "default"}
onClick={() => setTheme(isDark ? "light" : "dark")} onClick={() => setTheme(isDark ? "light" : "dark")}
className="w-full justify-start gap-2 text-sm" className={collapsed ? "h-10 w-10 justify-center" : "w-full justify-start gap-2 text-sm"}
title={isDark ? "라이트 모드" : "다크 모드"} title={isDark ? "라이트 모드" : "다크 모드"}
> >
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} {isDark ? <Sun className={collapsed ? "h-5 w-5" : "h-4 w-4"} /> : <Moon className={collapsed ? "h-5 w-5" : "h-4 w-4"} />}
{!collapsed && (isDark ? "라이트 모드" : "다크 모드")} {!collapsed && (isDark ? "라이트 모드" : "다크 모드")}
</Button> </Button>
); );