ERP-node/frontend/hooks/pop/useCartSync.ts

363 lines
11 KiB
TypeScript
Raw Normal View History

/**
* useCartSync - DB
*
* DB(cart_items ) <-> (dirty) .
*
* :
* 1. DB에서 screen_id + user_id의
* 2. addItem/removeItem/updateItem은 (DB , dirty )
* 3. saveToDb DB에 (//)
* 4. isDirty = DB
*
* :
* ```typescript
* const cart = useCartSync("SCR-001", "item_info");
*
* // 품목 추가 (로컬만, DB 미반영)
* cart.addItem({ row, quantity: 10 }, "D1710008");
*
* // DB 저장 (pop-icon 확인 모달에서 호출)
* await cart.saveToDb();
* ```
*/
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { dataApi } from "@/lib/api/data";
import type {
CartItem,
CartItemWithId,
CartSyncStatus,
CartItemStatus,
} from "@/lib/registry/pop-components/types";
// ===== 반환 타입 =====
export interface CartChanges {
toCreate: Record<string, unknown>[];
toUpdate: Record<string, unknown>[];
toDelete: (string | number)[];
}
export interface UseCartSyncReturn {
cartItems: CartItemWithId[];
savedItems: CartItemWithId[];
syncStatus: CartSyncStatus;
cartCount: number;
isDirty: boolean;
loading: boolean;
addItem: (item: CartItem, rowKey: string) => void;
removeItem: (rowKey: string) => void;
updateItemQuantity: (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => void;
isItemInCart: (rowKey: string) => boolean;
getCartItem: (rowKey: string) => CartItemWithId | undefined;
getChanges: (selectedColumns?: string[]) => CartChanges;
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
loadFromDb: () => Promise<void>;
resetToSaved: () => void;
}
// ===== DB 행 -> CartItemWithId 변환 =====
function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
let rowData: Record<string, unknown> = {};
try {
const raw = dbRow.row_data;
if (typeof raw === "string" && raw.trim()) {
rowData = JSON.parse(raw);
} else if (typeof raw === "object" && raw !== null) {
rowData = raw as Record<string, unknown>;
}
} catch {
rowData = {};
}
let packageEntries: CartItem["packageEntries"] | undefined;
try {
const raw = dbRow.package_entries;
if (typeof raw === "string" && raw.trim()) {
packageEntries = JSON.parse(raw);
} else if (Array.isArray(raw)) {
packageEntries = raw;
}
} catch {
packageEntries = undefined;
}
return {
row: rowData,
quantity: Number(dbRow.quantity) || 0,
packageUnit: (dbRow.package_unit as string) || undefined,
packageEntries,
cartId: (dbRow.id as string) || undefined,
sourceTable: (dbRow.source_table as string) || "",
rowKey: (dbRow.row_key as string) || "",
status: ((dbRow.status as string) || "in_cart") as CartItemStatus,
_origin: "db",
memo: (dbRow.memo as string) || undefined,
};
}
// ===== CartItemWithId -> DB 저장용 레코드 변환 =====
function cartItemToDbRecord(
item: CartItemWithId,
screenId: string,
selectedColumns?: string[],
): Record<string, unknown> {
const rowData =
selectedColumns && selectedColumns.length > 0
? Object.fromEntries(
Object.entries(item.row).filter(([k]) => selectedColumns.includes(k)),
)
: item.row;
return {
cart_type: "pop",
screen_id: screenId,
source_table: item.sourceTable,
row_key: item.rowKey,
row_data: JSON.stringify(rowData),
quantity: String(item.quantity),
unit: "",
package_unit: item.packageUnit || "",
package_entries: item.packageEntries ? JSON.stringify(item.packageEntries) : "",
status: item.status,
memo: item.memo || "",
};
}
// ===== dirty check: 두 배열의 내용이 동일한지 비교 =====
function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
if (a.length !== b.length) return false;
const serialize = (items: CartItemWithId[]) =>
items
.map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}`)
.sort()
.join("|");
return serialize(a) === serialize(b);
}
// ===== 훅 본체 =====
export function useCartSync(
screenId: string,
sourceTable: string,
): UseCartSyncReturn {
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("clean");
const [loading, setLoading] = useState(false);
const screenIdRef = useRef(screenId);
const sourceTableRef = useRef(sourceTable);
screenIdRef.current = screenId;
sourceTableRef.current = sourceTable;
// ----- DB에서 장바구니 로드 -----
const loadFromDb = useCallback(async () => {
if (!screenId || !sourceTable) return;
setLoading(true);
try {
const result = await dataApi.getTableData("cart_items", {
size: 500,
filters: {
screen_id: screenId,
cart_type: "pop",
status: "in_cart",
},
});
const items = (result.data || []).map(dbRowToCartItem);
setSavedItems(items);
setCartItems(items);
setSyncStatus("clean");
} catch (err) {
console.error("[useCartSync] DB 로드 실패:", err);
} finally {
setLoading(false);
}
}, [screenId, sourceTable]);
// 마운트 시 자동 로드
useEffect(() => {
loadFromDb();
}, [loadFromDb]);
// ----- dirty 상태 계산 -----
const isDirty = !areItemsEqual(cartItems, savedItems);
// isDirty 변경 시 syncStatus 자동 갱신
useEffect(() => {
if (syncStatus !== "saving") {
setSyncStatus(isDirty ? "dirty" : "clean");
}
}, [isDirty, syncStatus]);
// ----- 로컬 조작 (DB 미반영) -----
const addItem = useCallback(
(item: CartItem, rowKey: string) => {
setCartItems((prev) => {
const exists = prev.find((i) => i.rowKey === rowKey);
if (exists) {
return prev.map((i) =>
i.rowKey === rowKey
? { ...i, quantity: item.quantity, packageUnit: item.packageUnit, packageEntries: item.packageEntries, row: item.row }
: i,
);
}
const newItem: CartItemWithId = {
...item,
cartId: undefined,
sourceTable: sourceTableRef.current,
rowKey,
status: "in_cart",
_origin: "local",
};
return [...prev, newItem];
});
},
[],
);
const removeItem = useCallback((rowKey: string) => {
setCartItems((prev) => prev.filter((i) => i.rowKey !== rowKey));
}, []);
const updateItemQuantity = useCallback(
(rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => {
setCartItems((prev) =>
prev.map((i) =>
i.rowKey === rowKey
? {
...i,
quantity,
...(packageUnit !== undefined && { packageUnit }),
...(packageEntries !== undefined && { packageEntries }),
}
: i,
),
);
},
[],
);
const isItemInCart = useCallback(
(rowKey: string) => cartItems.some((i) => i.rowKey === rowKey),
[cartItems],
);
const getCartItem = useCallback(
(rowKey: string) => cartItems.find((i) => i.rowKey === rowKey),
[cartItems],
);
// ----- diff 계산 (백엔드 전송용) -----
const getChanges = useCallback((selectedColumns?: string[]): CartChanges => {
const currentScreenId = screenIdRef.current;
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDeleteItems = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
const toCreateItems = cartItems.filter((c) => !c.cartId);
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
const toUpdateItems = cartItems.filter((c) => {
if (!c.cartId) return false;
const saved = savedMap.get(c.rowKey);
if (!saved) return false;
return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status;
});
return {
toCreate: toCreateItems.map((item) => cartItemToDbRecord(item, currentScreenId, selectedColumns)),
toUpdate: toUpdateItems.map((item) => ({ id: item.cartId, ...cartItemToDbRecord(item, currentScreenId, selectedColumns) })),
toDelete: toDeleteItems.map((item) => item.cartId!),
};
}, [cartItems, savedItems]);
// ----- DB 저장 (일괄) -----
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
setSyncStatus("saving");
try {
const currentScreenId = screenIdRef.current;
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDelete = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
// 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨)
const toCreate = cartItems.filter((c) => !c.cartId);
// 수정 대상: 양쪽 다 존재하고 cartId 있으면서 내용이 다른 것
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
const toUpdate = cartItems.filter((c) => {
if (!c.cartId) return false;
const saved = savedMap.get(c.rowKey);
if (!saved) return false;
return (
c.quantity !== saved.quantity ||
c.packageUnit !== saved.packageUnit ||
c.status !== saved.status
);
});
const promises: Promise<unknown>[] = [];
for (const item of toDelete) {
promises.push(dataApi.updateRecord("cart_items", item.cartId!, { status: "cancelled" }));
}
for (const item of toCreate) {
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
promises.push(dataApi.createRecord("cart_items", record));
}
for (const item of toUpdate) {
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
promises.push(dataApi.updateRecord("cart_items", item.cartId!, record));
}
await Promise.all(promises);
// 저장 후 DB에서 다시 로드하여 cartId 등을 최신화
await loadFromDb();
return true;
} catch (err) {
console.error("[useCartSync] DB 저장 실패:", err);
setSyncStatus("dirty");
return false;
}
}, [cartItems, savedItems, loadFromDb]);
// ----- 로컬 변경 취소 -----
const resetToSaved = useCallback(() => {
setCartItems(savedItems);
setSyncStatus("clean");
}, [savedItems]);
return {
cartItems,
savedItems,
syncStatus,
cartCount: cartItems.length,
isDirty,
loading,
addItem,
removeItem,
updateItemQuantity,
isItemInCart,
getCartItem,
getChanges,
saveToDb,
loadFromDb,
resetToSaved,
};
}