Files
vexplor_dev/frontend/hooks/pop/useCartSync.ts
kmh 4ed4d5f66e WIP: POP + packaging 작업 중
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:55:28 +09:00

462 lines
13 KiB
TypeScript

/**
* useCartSync - 장바구니 DB 동기화 훅
*
* DB(cart_items 테이블) <-> 로컬 상태를 동기화하고 변경사항(dirty)을 감지한다.
*
* 동작 방식:
* 1. 마운트 시 DB에서 해당 카테고리(inbound/outbound)의 장바구니를 로드
* 2. addItem/removeItem/updateItem은 로컬 상태만 변경 (DB 미반영, dirty 상태)
* 3. saveToDb 호출 시 로컬 상태를 DB에 일괄 반영 (추가/수정/삭제)
* 4. isDirty = 로컬 상태와 DB 마지막 로드 상태의 차이 존재 여부
*
* 사용 예시:
* ```typescript
* const cart = useCartSync("inbound");
*
* // 품목 추가 (로컬만, DB 미반영) — sourceTable은 항목별로 전달
* cart.addItem({ row, quantity: 10 }, "D1710008", "purchase_detail");
*
* // DB 저장 (pop-icon 확인 모달에서 호출)
* await cart.saveToDb();
* ```
*/
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { dataApi } from "@/lib/api/data";
import type {
CartItem,
CartItemStatus,
CartItemWithId,
CartSyncStatus,
} from "@/lib/registry/pop-components/types";
// ===== 반환 타입 =====
export interface CartChanges {
toCreate: Record<string, unknown>[];
toUpdate: Record<string, unknown>[];
toDelete: (string | number)[];
}
export type CartCategory = "inbound" | "outbound";
export interface UseCartSyncReturn {
cartItems: CartItemWithId[];
savedItems: CartItemWithId[];
syncStatus: CartSyncStatus;
cartCount: number;
isDirty: boolean;
loading: boolean;
addItem: (item: CartItem, rowKey: string, sourceTable?: string) => void;
removeItem: (rowKey: string) => void;
updateItemQuantity: (
rowKey: string,
quantity: number,
packageUnit?: string,
packageEntries?: CartItem["packageEntries"],
) => void;
updateItemRow: (rowKey: string, partialRow: Record<string, unknown>) => 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,
cartType: string,
selectedColumns?: string[],
screenId?: string,
): Record<string, unknown> {
const rowData =
selectedColumns && selectedColumns.length > 0
? Object.fromEntries(
Object.entries(item.row).filter(([k]) => selectedColumns.includes(k)),
)
: item.row;
const record: Record<string, unknown> = {
cart_type: cartType,
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 || "",
};
// 레거시 모드: screen_id 포함
if (screenId) {
record.screen_id = screenId;
}
return record;
}
// ===== 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}:${JSON.stringify(item.row)}`,
)
.sort()
.join("|");
return serialize(a) === serialize(b);
}
// ===== 훅 본체 =====
// 오버로드: 카테고리 기반 (신규) + 레거시 screen_id 기반 (PopCardListComponent 등)
export function useCartSync(category: CartCategory): UseCartSyncReturn;
export function useCartSync(screenId: string, sourceTable: string): UseCartSyncReturn;
export function useCartSync(
categoryOrScreenId: string,
sourceTable?: string,
): UseCartSyncReturn {
// 레거시 호출 감지: 2번째 인자가 있으면 구 시그니처 (screen_id 기반)
const isLegacy = sourceTable !== undefined;
const cartTypeValue = isLegacy ? "pop" : `pop_${categoryOrScreenId}`;
const screenIdValue = isLegacy ? categoryOrScreenId : undefined;
const legacySourceTable = isLegacy ? sourceTable : undefined;
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("clean");
const [loading, setLoading] = useState(false);
const categoryRef = useRef(categoryOrScreenId);
categoryRef.current = categoryOrScreenId;
const cartTypeRef = useRef(cartTypeValue);
cartTypeRef.current = cartTypeValue;
const legacySourceTableRef = useRef(legacySourceTable);
legacySourceTableRef.current = legacySourceTable;
// ----- DB에서 장바구니 로드 -----
const loadFromDb = useCallback(async () => {
if (!categoryOrScreenId) return;
setLoading(true);
try {
const filters: Record<string, string> = {
cart_type: cartTypeValue,
status: "in_cart",
};
// 레거시: screen_id로 필터링
if (screenIdValue) {
filters.screen_id = screenIdValue;
}
const result = await dataApi.getTableData("cart_items", {
size: 500,
filters,
});
const items = (result.data || []).map(dbRowToCartItem);
setSavedItems(items);
setCartItems(items);
setSyncStatus("clean");
} catch (err) {
console.error("[useCartSync] DB 로드 실패:", err);
} finally {
setLoading(false);
}
}, [categoryOrScreenId, cartTypeValue, screenIdValue]);
// 마운트 시 자동 로드
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, sourceTable?: 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: sourceTable || legacySourceTableRef.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,
),
);
},
[],
);
// row 객체에 임의 필드를 부분 업데이트 (예: inspectionResult)
const updateItemRow = useCallback(
(rowKey: string, partialRow: Record<string, unknown>) => {
setCartItems((prev) =>
prev.map((i) =>
i.rowKey === rowKey ? { ...i, row: { ...i.row, ...partialRow } } : 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 currentCartType = cartTypeRef.current;
const currentScreenId = isLegacy ? categoryRef.current : undefined;
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;
const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row);
return (
c.quantity !== saved.quantity ||
c.packageUnit !== saved.packageUnit ||
c.status !== saved.status ||
rowChanged
);
});
return {
toCreate: toCreateItems.map((item) =>
cartItemToDbRecord(item, currentCartType, selectedColumns, currentScreenId),
),
toUpdate: toUpdateItems.map((item) => ({
id: item.cartId,
...cartItemToDbRecord(item, currentCartType, selectedColumns, currentScreenId),
})),
toDelete: toDeleteItems.map((item) => item.cartId!),
};
},
[cartItems, savedItems],
);
// ----- DB 저장 (일괄) -----
const saveToDb = useCallback(
async (selectedColumns?: string[]): Promise<boolean> => {
setSyncStatus("saving");
try {
const currentCartType = cartTypeRef.current;
const currentScreenId = isLegacy ? categoryRef.current : undefined;
// 삭제 대상: 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;
const rowChanged =
JSON.stringify(c.row) !== JSON.stringify(saved.row);
return (
c.quantity !== saved.quantity ||
c.packageUnit !== saved.packageUnit ||
c.status !== saved.status ||
rowChanged
);
});
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,
currentCartType,
selectedColumns,
currentScreenId,
);
// cart_items.id는 NOT NULL + 자동생성 없음 → UUID 직접 생성
const recordWithId = { id: crypto.randomUUID(), ...record };
promises.push(dataApi.createRecord("cart_items", recordWithId));
}
for (const item of toUpdate) {
const record = cartItemToDbRecord(
item,
currentCartType,
selectedColumns,
currentScreenId,
);
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,
updateItemRow,
isItemInCart,
getCartItem,
getChanges,
saveToDb,
loadFromDb,
resetToSaved,
};
}