2026-02-26 16:00:07 +09:00
|
|
|
/**
|
|
|
|
|
* 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";
|
|
|
|
|
|
|
|
|
|
// ===== 반환 타입 =====
|
|
|
|
|
|
2026-03-05 17:22:30 +09:00
|
|
|
export interface CartChanges {
|
|
|
|
|
toCreate: Record<string, unknown>[];
|
|
|
|
|
toUpdate: Record<string, unknown>[];
|
|
|
|
|
toDelete: (string | number)[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
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;
|
|
|
|
|
|
2026-03-05 17:22:30 +09:00
|
|
|
getChanges: (selectedColumns?: string[]) => CartChanges;
|
2026-02-26 16:00:07 +09:00
|
|
|
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 {
|
2026-03-03 17:46:50 +09:00
|
|
|
cart_type: "pop",
|
2026-02-26 16:00:07 +09:00
|
|
|
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 () => {
|
2026-03-03 15:30:07 +09:00
|
|
|
if (!screenId || !sourceTable) return;
|
2026-02-26 16:00:07 +09:00
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await dataApi.getTableData("cart_items", {
|
|
|
|
|
size: 500,
|
|
|
|
|
filters: {
|
|
|
|
|
screen_id: screenId,
|
2026-03-03 17:46:50 +09:00
|
|
|
cart_type: "pop",
|
2026-02-26 16:00:07 +09:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-03 15:30:07 +09:00
|
|
|
}, [screenId, sourceTable]);
|
2026-02-26 16:00:07 +09:00
|
|
|
|
|
|
|
|
// 마운트 시 자동 로드
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-05 17:22:30 +09:00
|
|
|
// ----- 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]);
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
// ----- 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) {
|
2026-03-03 15:30:07 +09:00
|
|
|
promises.push(dataApi.updateRecord("cart_items", item.cartId!, { status: "cancelled" }));
|
2026-02-26 16:00:07 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const item of toCreate) {
|
2026-03-03 15:30:07 +09:00
|
|
|
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
|
2026-02-26 16:00:07 +09:00
|
|
|
promises.push(dataApi.createRecord("cart_items", record));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const item of toUpdate) {
|
2026-03-03 15:30:07 +09:00
|
|
|
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
|
2026-02-26 16:00:07 +09:00
|
|
|
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,
|
2026-03-05 17:22:30 +09:00
|
|
|
getChanges,
|
2026-02-26 16:00:07 +09:00
|
|
|
saveToDb,
|
|
|
|
|
loadFromDb,
|
|
|
|
|
resetToSaved,
|
|
|
|
|
};
|
|
|
|
|
}
|