/** * 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 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; saveToDb: (selectedColumns?: string[]) => Promise; loadFromDb: () => Promise; resetToSaved: () => void; } // ===== DB 행 -> CartItemWithId 변환 ===== function dbRowToCartItem(dbRow: Record): CartItemWithId { let rowData: Record = {}; 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; } } 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 { 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([]); const [savedItems, setSavedItems] = useState([]); const [syncStatus, setSyncStatus] = useState("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], ); // ----- DB 저장 (일괄) ----- const saveToDb = useCallback(async (selectedColumns?: string[]): Promise => { 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[] = []; 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, saveToDb, loadFromDb, resetToSaved, }; }