jskim-node #388

Merged
kjs merged 58 commits from jskim-node into main 2026-02-13 09:59:55 +09:00
11 changed files with 80 additions and 108 deletions
Showing only changes of commit d7f900d8ae - Show all commits

View File

@ -688,7 +688,7 @@ router.post(
authenticateToken, authenticateToken,
async (req: AuthenticatedRequest, res) => { async (req: AuthenticatedRequest, res) => {
try { try {
const { tableName, parentKeys, records } = req.body; const { tableName, parentKeys, records, deleteOrphans = true } = req.body;
// 입력값 검증 // 입력값 검증
if (!tableName || !parentKeys || !records || !Array.isArray(records)) { if (!tableName || !parentKeys || !records || !Array.isArray(records)) {
@ -722,7 +722,8 @@ router.post(
parentKeys, parentKeys,
records, records,
req.user?.companyCode, req.user?.companyCode,
req.user?.userId req.user?.userId,
deleteOrphans
); );
if (!result.success) { if (!result.success) {

View File

@ -1354,7 +1354,8 @@ class DataService {
parentKeys: Record<string, any>, parentKeys: Record<string, any>,
records: Array<Record<string, any>>, records: Array<Record<string, any>>,
userCompany?: string, userCompany?: string,
userId?: string userId?: string,
deleteOrphans: boolean = true
): Promise< ): Promise<
ServiceResponse<{ inserted: number; updated: number; deleted: number }> ServiceResponse<{ inserted: number; updated: number; deleted: number }>
> { > {
@ -1422,11 +1423,6 @@ class DataService {
const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn])); const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn]));
const processedIds = new Set<string>(); // UPDATE 처리된 id 추적 const processedIds = new Set<string>(); // UPDATE 처리된 id 추적
// DEBUG: 수신된 레코드와 기존 레코드 id 확인
console.log(`🔑 [UPSERT DEBUG] pkColumn: ${pkColumn}`);
console.log(`🔑 [UPSERT DEBUG] existingIds:`, Array.from(existingIds));
console.log(`🔑 [UPSERT DEBUG] records received:`, records.map((r: any) => ({ id: r[pkColumn], keys: Object.keys(r) })));
for (const newRecord of records) { for (const newRecord of records) {
// 날짜 필드 정규화 // 날짜 필드 정규화
const normalizedRecord: Record<string, any> = {}; const normalizedRecord: Record<string, any> = {};

View File

@ -289,6 +289,20 @@ select {
} }
} }
/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */
[data-sonner-toaster] [data-sonner-toast] {
animation: none !important;
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] {
animation: none !important;
}
[data-sonner-toaster] [data-sonner-toast][data-removed="true"] {
animation: none !important;
}
/* ===== Print Styles ===== */ /* ===== Print Styles ===== */
@media print { @media print {
* { * {

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { cn } from "@/lib/utils";
import { Database, Cog } from "lucide-react"; import { Database, Cog } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -6389,19 +6390,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
{activeLayerId > 1 && ( {activeLayerId > 1 && (
<div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30"> <div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30">
<div className="h-2 w-2 rounded-full bg-amber-500" /> <div className="h-2 w-2 rounded-full bg-amber-500" />
<span className="text-xs font-medium"> <span className="text-xs font-medium"> {activeLayerId} </span>
{activeLayerId}
{layerRegions[activeLayerId] && (
<span className="ml-2 text-amber-600">
(: {layerRegions[activeLayerId].width} x {layerRegions[activeLayerId].height}px)
</span>
)}
{!layerRegions[activeLayerId] && (
<span className="ml-2 text-red-500">
( - )
</span>
)}
</span>
</div> </div>
)} )}

View File

@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/80", "fixed inset-0 z-[999] bg-black/80",
className, className,
)} )}
{...props} {...props}
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg", "bg-background fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
className, className,
)} )}
{...props} {...props}

View File

@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/60", "fixed inset-0 z-[999] bg-black/60",
className, className,
)} )}
{...props} {...props}
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg", "bg-background fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
className, className,
)} )}
{...props} {...props}

View File

@ -46,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className, className,
)} )}
{...props} {...props}
@ -63,7 +63,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className, className,
)} )}
{...props} {...props}

View File

@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[2000] w-72 rounded-md border p-4 shadow-md outline-none", "bg-popover text-popover-foreground z-[2000] w-72 rounded-md border p-4 shadow-md outline-none",
className, className,
)} )}
{...props} {...props}

View File

@ -16,16 +16,14 @@ const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal; const SheetPortal = SheetPrimitive.Portal;
const sheetVariants = cva( const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", "fixed z-50 gap-4 bg-background p-6 shadow-lg",
{ {
variants: { variants: {
side: { side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", top: "inset-x-0 top-0 border-b",
bottom: bottom: "inset-x-0 bottom-0 border-t",
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", left: "inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", right: "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
}, },
}, },
defaultVariants: { defaultVariants: {
@ -60,7 +58,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( className={cn(
"bg-background/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm", "bg-background/80 fixed inset-0 z-50 backdrop-blur-sm",
className, className,
)} )}
{...props} {...props}

View File

@ -236,7 +236,8 @@ export const dataApi = {
upsertGroupedRecords: async ( upsertGroupedRecords: async (
tableName: string, tableName: string,
parentKeys: Record<string, any>, parentKeys: Record<string, any>,
records: Array<Record<string, any>> records: Array<Record<string, any>>,
options?: { deleteOrphans?: boolean }
): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => { ): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => {
try { try {
console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", { console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", {
@ -251,6 +252,7 @@ export const dataApi = {
tableName, tableName,
parentKeys, parentKeys,
records, records,
deleteOrphans: options?.deleteOrphans ?? true, // 기본값: true (기존 동작 유지)
}; };
console.log("📦 [dataApi.upsertGroupedRecords] 요청 본문 (JSON):", JSON.stringify(requestBody, null, 2)); console.log("📦 [dataApi.upsertGroupedRecords] 요청 본문 (JSON):", JSON.stringify(requestBody, null, 2));

View File

@ -223,33 +223,34 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const additionalFields = componentConfig.additionalFields || []; const additionalFields = componentConfig.additionalFields || [];
const firstRecord = dataArray[0]; const firstRecord = dataArray[0];
// 수정 모드: 다른 sourceTable의 데이터도 추가 로드 (예: customer_item_mapping) // 수정 모드: 모든 관련 테이블의 데이터를 API로 전체 로드
let mappingData: Record<string, any> | null = null; // sourceData는 클릭한 1개 레코드만 포함할 수 있으므로, API로 전체를 다시 가져옴
// URL의 tableName = 이미 데이터가 로드된 테이블. 그 외 sourceTable은 추가 조회 필요
const editTableName = new URLSearchParams(window.location.search).get("tableName"); const editTableName = new URLSearchParams(window.location.search).get("tableName");
const otherTables = groups const allTableData: Record<string, Record<string, any>[]> = {};
.filter((g) => g.sourceTable && g.sourceTable !== editTableName)
.map((g) => g.sourceTable!)
.filter((v, i, a) => a.indexOf(v) === i); // 중복 제거
if (otherTables.length > 0 && firstRecord.customer_id && firstRecord.item_id) { if (firstRecord.customer_id && firstRecord.item_id) {
try { try {
const { dataApi } = await import("@/lib/api/data"); const { dataApi } = await import("@/lib/api/data");
for (const otherTable of otherTables) { // 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거)
// getTableData 반환: { data: any[], total, page, size } (success 필드 없음) const allTables = groups
const response = await dataApi.getTableData(otherTable, { .map((g) => g.sourceTable || editTableName)
.filter((v, i, a) => v && a.indexOf(v) === i) as string[];
for (const table of allTables) {
const response = await dataApi.getTableData(table, {
filters: { filters: {
customer_id: firstRecord.customer_id, customer_id: firstRecord.customer_id,
item_id: firstRecord.item_id, item_id: firstRecord.item_id,
}, },
sortBy: "created_date",
sortOrder: "desc",
}); });
if (response.data && response.data.length > 0) { if (response.data && response.data.length > 0) {
mappingData = response.data[0]; allTableData[table] = response.data;
} }
} }
} catch (err) { } catch (err) {
console.error("❌ 매핑 데이터 로드 실패:", err); console.error("❌ 편집 데이터 전체 로드 실패:", err);
} }
} }
@ -263,41 +264,17 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
return; return;
} }
// 이 그룹의 sourceTable에 따라 데이터 소스 결정 // 이 그룹의 sourceTable 결정 → API에서 가져온 전체 데이터 사용
const editTableName = new URLSearchParams(window.location.search).get("tableName"); const groupTable = group.sourceTable || editTableName || "";
const isOtherTable = group.sourceTable && group.sourceTable !== editTableName; // 현재 테이블만 sourceData fallback 허용 (다른 테이블은 빈 배열 → id 크로스오염 방지)
const isCurrentTable = !group.sourceTable || group.sourceTable === editTableName;
const groupDataList = allTableData[groupTable] || (isCurrentTable ? dataArray : []);
if (isOtherTable && mappingData) { {
// 다른 테이블 그룹 (예: customer_item_mapping) → mappingData에서 로드 // 모든 테이블 그룹: API에서 가져온 전체 레코드를 entry로 변환
const entryData: Record<string, any> = {};
groupFields.forEach((field: any) => {
let fieldValue = mappingData![field.name];
// autoFillFrom 로직
if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) {
fieldValue = firstRecord[field.autoFillFrom] || firstRecord.item_id;
}
if (fieldValue !== undefined && fieldValue !== null) {
entryData[field.name] = fieldValue;
}
});
if (Object.keys(entryData).length > 0) {
mainFieldGroups[group.id] = [{
id: `${group.id}_entry_1`,
// DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE
_dbRecordId: mappingData!.id || null,
...entryData,
}];
} else {
mainFieldGroups[group.id] = [];
}
} else {
// 현재 테이블 그룹 (예: customer_item_prices) → dataArray에서 로드
const entriesMap = new Map<string, GroupEntry>(); const entriesMap = new Map<string, GroupEntry>();
dataArray.forEach((record) => { groupDataList.forEach((record) => {
const entryData: Record<string, any> = {}; const entryData: Record<string, any> = {};
groupFields.forEach((field: any) => { groupFields.forEach((field: any) => {
@ -355,8 +332,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const entryKey = JSON.stringify(entryData); const entryKey = JSON.stringify(entryData);
if (!entriesMap.has(entryKey)) { if (!entriesMap.has(entryKey)) {
// DEBUG: record.id 확인 (추후 삭제)
console.log("🔑 [LOAD] record.id:", record.id, "record keys:", Object.keys(record));
entriesMap.set(entryKey, { entriesMap.set(entryKey, {
id: `${group.id}_entry_${entriesMap.size + 1}`, id: `${group.id}_entry_${entriesMap.size + 1}`,
// DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE // DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE
@ -678,37 +653,36 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const itemParentKeys = { ...parentKeys, item_id: itemId }; const itemParentKeys = { ...parentKeys, item_id: itemId };
// === Step 1: 메인 테이블(customer_item_mapping) 저장 === // === Step 1: 메인 테이블(customer_item_mapping) 저장 ===
const mappingRecord: Record<string, any> = {}; // 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음)
const mappingRecords: Record<string, any>[] = [];
mainGroups.forEach((group) => { mainGroups.forEach((group) => {
const entries = item.fieldGroups[group.id] || []; const entries = item.fieldGroups[group.id] || [];
const groupFields = additionalFields.filter((f) => f.groupId === group.id); const groupFields = additionalFields.filter((f) => f.groupId === group.id);
if (entries.length > 0) { entries.forEach((entry) => {
const record: Record<string, any> = {};
groupFields.forEach((field) => { groupFields.forEach((field) => {
const val = entries[0][field.name]; const val = entry[field.name];
// 사용자가 실제 입력한 값만 포함 (빈 문자열, null 제외)
if (val !== undefined && val !== null && val !== "") { if (val !== undefined && val !== null && val !== "") {
mappingRecord[field.name] = val; record[field.name] = val;
} }
}); });
// 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE // 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE
if (entries[0]._dbRecordId) { if (entry._dbRecordId) {
mappingRecord.id = entries[0]._dbRecordId; record.id = entry._dbRecordId;
} }
} // item_id는 정확한 itemId 변수 사용 (autoFillFrom:"id" 오작동 방지)
record.item_id = itemId;
// autoFillFrom 필드 처리 (item_id 등) // 나머지 autoFillFrom 필드 처리
// 단, item_id는 이미 정확한 itemId 변수를 사용 (autoFillFrom:"id"가 수정 모드에서 오작동 방지) groupFields.forEach((field) => {
groupFields.forEach((field) => { if (field.name !== "item_id" && field.autoFillFrom && item.originalData) {
if (field.name === "item_id") { const value = item.originalData[field.autoFillFrom];
// item_id는 위에서 계산된 정확한 itemId 사용 if (value !== undefined && value !== null && !record[field.name]) {
mappingRecord.item_id = itemId; record[field.name] = value;
} else if (field.autoFillFrom && item.originalData) { }
const value = item.originalData[field.autoFillFrom];
if (value !== undefined && value !== null) {
mappingRecord[field.name] = value;
} }
} });
mappingRecords.push(record);
}); });
}); });
@ -716,7 +690,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const mappingResult = await dataApi.upsertGroupedRecords( const mappingResult = await dataApi.upsertGroupedRecords(
mainTable, mainTable,
itemParentKeys, itemParentKeys,
[mappingRecord], mappingRecords,
); );
} catch (err) { } catch (err) {
console.error(`${mainTable} 저장 실패:`, err); console.error(`${mainTable} 저장 실패:`, err);
@ -757,8 +731,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (entry._dbRecordId) { if (entry._dbRecordId) {
priceRecord.id = entry._dbRecordId; priceRecord.id = entry._dbRecordId;
} }
// DEBUG: id 전달 확인용 (추후 삭제)
console.log("🔑 [SAVE] entry._dbRecordId:", entry._dbRecordId, "→ priceRecord.id:", priceRecord.id, "entry keys:", Object.keys(entry));
priceRecords.push(priceRecord); priceRecords.push(priceRecord);
} }
}); });