import type { DeepPartial } from "react-hook-form"; import type { TableState } from "~/components/core/data-table"; /** * Chuyển đổi TableState thành URL query parameters * @param state - TableState object từ onStateChange callback * @returns URLSearchParams object */ export function stateToURLQuery(state: TableState): URLSearchParams { const params = new URLSearchParams(); // 📄 Pagination if (state.pagination.currentPage > 1) { params.set("page", state.pagination.currentPage.toString()); } if (state.pagination.pageSize !== 10) { // Default page size params.set("size", state.pagination.pageSize.toString()); } // 🔍 Search if (state.search && state.search.trim()) { params.set("search", state.search.trim()); } // 🔄 Sort if (state.sort.key) { params.set("sort", String(state.sort.key)); if (state.sort.direction !== "asc") { // Default direction params.set("dir", state.sort.direction); } } // 🎯 Select Filters Object.entries(state.filters.select).forEach(([key, values]) => { if (values && values.length > 0) { params.set(`filter_${key}`, values.join(",")); } }); // 📝 Text Filters Object.entries(state.filters.text).forEach(([key, value]) => { if (value && value.trim()) { params.set(`text_${key}`, value.trim()); } }); // 📅 Date Filters Object.entries(state.filters.date).forEach(([key, value]) => { if (value) { params.set(`date_${key}`, value.toISOString().split("T")[0]); // YYYY-MM-DD format } }); // 📅 Date Range Filters Object.entries(state.filters.dateRange).forEach(([key, range]) => { if (range.from || range.to) { const rangeValue = [ range.from ? range.from.toISOString().split("T")[0] : "", range.to ? range.to.toISOString().split("T")[0] : "", ].join("|"); params.set(`daterange_${key}`, rangeValue); } }); // 🔢 Number Filters Object.entries(state.filters.number).forEach(([key, value]) => { if (value !== undefined) { params.set(`num_${key}`, value.toString()); } }); // 🔢 Number Range Filters Object.entries(state.filters.numberRange).forEach(([key, range]) => { if (range.min !== undefined || range.max !== undefined) { const rangeValue = [ range.min !== undefined ? range.min.toString() : "", range.max !== undefined ? range.max.toString() : "", ].join("|"); params.set(`numrange_${key}`, rangeValue); } }); return params; } /** * Chuyển đổi URL query parameters thành partial state object * @param searchParams - URLSearchParams hoặc string query * @returns Partial state object có thể dùng làm initialState */ export function urlQueryToState( searchParams: URLSearchParams | string ): Partial> { const params = typeof searchParams === "string" ? new URLSearchParams(searchParams) : searchParams; const state: Partial> = { pagination: { currentPage: 1, pageSize: 10, totalPages: 0, totalItems: 0, startIndex: 0, endIndex: 0, }, search: "", sort: { key: null, direction: "asc" }, filters: { select: {}, text: {}, date: {}, dateRange: {}, number: {}, numberRange: {}, }, }; // 📄 Pagination const page = params.get("page"); const size = params.get("size"); if (page) { const pageNum = Number.parseInt(page, 10); if (!isNaN(pageNum) && pageNum > 0) { state.pagination!.currentPage = pageNum; } } if (size) { const sizeNum = Number.parseInt(size, 10); if (!isNaN(sizeNum) && sizeNum > 0) { state.pagination!.pageSize = sizeNum; } } // 🔍 Search const search = params.get("search"); if (search) { state.search = decodeURIComponent(search); } // 🔄 Sort const sort = params.get("sort"); const dir = params.get("dir"); if (sort) { state.sort!.key = sort as keyof T; state.sort!.direction = dir === "desc" ? "desc" : "asc"; } // 🎯 Parse all filter parameters for (const [key, value] of params.entries()) { if (!value) continue; try { // Select Filters (filter_*) if (key.startsWith("filter_")) { const filterKey = key.replace("filter_", ""); state.filters!.select![filterKey] = value .split(",") .filter((v) => v.trim()); } // Text Filters (text_*) else if (key.startsWith("text_")) { const filterKey = key.replace("text_", ""); state.filters!.text![filterKey] = decodeURIComponent(value); } // Date Filters (date_*) else if (key.startsWith("date_")) { const filterKey = key.replace("date_", ""); const date = new Date(value); if (!isNaN(date.getTime())) { state.filters!.date![filterKey] = date; } } // Date Range Filters (daterange_*) else if (key.startsWith("daterange_")) { const filterKey = key.replace("daterange_", ""); const [fromStr, toStr] = value.split("|"); const range: { from?: Date; to?: Date } = {}; if (fromStr) { const fromDate = new Date(fromStr); if (!isNaN(fromDate.getTime())) { range.from = fromDate; } } if (toStr) { const toDate = new Date(toStr); if (!isNaN(toDate.getTime())) { range.to = toDate; } } if (range.from || range.to) { state.filters!.dateRange![filterKey] = range; } } // Number Filters (num_*) else if (key.startsWith("num_")) { const filterKey = key.replace("num_", ""); const num = Number.parseFloat(value); if (!isNaN(num)) { state.filters!.number![filterKey] = num; } } // Number Range Filters (numrange_*) else if (key.startsWith("numrange_")) { const filterKey = key.replace("numrange_", ""); const [minStr, maxStr] = value.split("|"); const range: { min?: number; max?: number } = {}; if (minStr) { const min = Number.parseFloat(minStr); if (!isNaN(min)) { range.min = min; } } if (maxStr) { const max = Number.parseFloat(maxStr); if (!isNaN(max)) { range.max = max; } } if (range.min !== undefined || range.max !== undefined) { state.filters!.numberRange![filterKey] = range; } } } catch (error) { console.warn(`Failed to parse URL parameter ${key}:`, error); } } return state; } /** * Helper function để tạo shareable URL với current state * @param baseUrl - Base URL (thường là window.location.origin + pathname) * @param state - TableState object * @returns Complete URL string */ export function createShareableURL( baseUrl: string, state: TableState ): string { const params = stateToURLQuery(state); const queryString = params.toString(); return queryString ? `${baseUrl}?${queryString}` : baseUrl; } /** * Helper function để update browser URL mà không reload page * @param state - TableState object * @param replaceState - Dùng replaceState thay vì pushState (default: true) */ export function updateBrowserURL( state: TableState, replaceState = true ): void { const params = stateToURLQuery(state); const queryString = params.toString(); const newUrl = queryString ? `${window.location.pathname}?${queryString}` : window.location.pathname; if (replaceState) { window.history.replaceState({}, "", newUrl); } else { window.history.pushState({}, "", newUrl); } } /** * Helper function để get current URL params * @returns URLSearchParams từ current URL */ export function getCurrentURLParams(): URLSearchParams { return new URLSearchParams(window.location.search); } /** * Helper function để compress state thành base64 string (cho URL ngắn hơn) * @param state - TableState object * @returns Base64 encoded string */ export function compressStateToBase64(state: TableState): string { const minimalState = { p: state.pagination.currentPage > 1 ? state.pagination.currentPage : undefined, s: state.pagination.pageSize !== 10 ? state.pagination.pageSize : undefined, q: state.search || undefined, sort: state.sort.key ? { k: state.sort.key, d: state.sort.direction !== "asc" ? state.sort.direction : undefined, } : undefined, f: { sel: Object.keys(state.filters.select).length > 0 ? state.filters.select : undefined, txt: Object.keys(state.filters.text).length > 0 ? state.filters.text : undefined, dt: Object.keys(state.filters.date).length > 0 ? Object.fromEntries( Object.entries(state.filters.date).map(([k, v]) => [ k, v?.toISOString(), ]) ) : undefined, dtr: Object.keys(state.filters.dateRange).length > 0 ? Object.fromEntries( Object.entries(state.filters.dateRange).map(([k, v]) => [ k, { f: v.from?.toISOString(), t: v.to?.toISOString(), }, ]) ) : undefined, num: Object.keys(state.filters.number).length > 0 ? state.filters.number : undefined, numr: Object.keys(state.filters.numberRange).length > 0 ? state.filters.numberRange : undefined, }, }; // Remove undefined values const cleanState = JSON.parse(JSON.stringify(minimalState)); return btoa(JSON.stringify(cleanState)); } /** * Helper function để decompress base64 string thành state * @param base64String - Base64 encoded state * @returns Partial state object */ export function decompressStateFromBase64( base64String: string ): Partial> { try { const minimalState = JSON.parse(atob(base64String)); const state: Partial> = { pagination: { currentPage: minimalState.p || 1, pageSize: minimalState.s || 10, totalPages: 0, totalItems: 0, startIndex: 0, endIndex: 0, }, search: minimalState.q || "", sort: { key: minimalState.sort?.k || null, direction: minimalState.sort?.d || "asc", }, filters: { select: minimalState.f?.sel || {}, text: minimalState.f?.txt || {}, date: minimalState.f?.dt ? Object.fromEntries( Object.entries(minimalState.f.dt).map(([k, v]) => [ k, new Date(v as string), ]) ) : {}, dateRange: minimalState.f?.dtr ? Object.fromEntries( Object.entries(minimalState.f.dtr).map( ([k, v]: [string, any]) => [ k, { from: v.f ? new Date(v.f) : undefined, to: v.t ? new Date(v.t) : undefined, }, ] ) ) : {}, number: minimalState.f?.num || {}, numberRange: minimalState.f?.numr || {}, }, }; return state; } catch (error) { console.error("Failed to decompress state from base64:", error); return {}; } } type PartialTableState = { pagination?: { currentPage?: number; pageSize?: number; }; search?: string; sort?: { key?: keyof T | string | null; direction?: "asc" | "desc"; }; filters?: { select?: Record; text?: Record; date?: Record; dateRange?: Record; number?: Record; numberRange?: Record; }; }; /** * Cập nhật từng phần của URLSearchParams */ export function updateURLQueryPartially( currentParams: URLSearchParams, partial: DeepPartial> ): URLSearchParams { const params = new URLSearchParams(currentParams.toString()); // Clone // 📄 Pagination if (partial.pagination) { if (partial.pagination.currentPage !== undefined) { if (partial.pagination.currentPage > 1) { params.set("page", partial.pagination.currentPage.toString()); } else { params.delete("page"); } } if (partial.pagination.pageSize !== undefined) { if (partial.pagination.pageSize !== 10) { params.set("size", partial.pagination.pageSize.toString()); } else { params.delete("size"); } } } // 🔍 Search if (partial.search !== undefined) { const val = partial.search.trim(); if (val) { params.set("search", val); } else { params.delete("search"); } } // 🔄 Sort if (partial.sort) { if (partial.sort.key) { params.set("sort", String(partial.sort.key)); if (partial.sort.direction && partial.sort.direction !== "asc") { params.set("dir", partial.sort.direction); } else { params.delete("dir"); } } else { params.delete("sort"); params.delete("dir"); } } // 🎯 Filters const filters = partial.filters; // // Select // if (filters?.select) { // Object.entries(filters.select).forEach(([key, values]) => { // if (values.length > 0) { // params.set(`filter_${key}`, values.join(",")); // } else { // params.delete(`filter_${key}`); // } // }); // } // Text if (filters?.text) { Object.entries(filters.text).forEach(([key, value]) => { if (value && value.trim()) { params.set(`text_${key}`, value.trim()); } else { params.delete(`text_${key}`); } }); } // Date if (filters?.date) { Object.entries(filters.date).forEach(([key, value]) => { if (value) { params.set(`date_${key}`, value.toISOString().split("T")[0]); } else { params.delete(`date_${key}`); } }); } // DateRange if (filters?.dateRange) { Object.entries(filters.dateRange).forEach(([key, range]) => { const from = range?.from ? range.from.toISOString().split("T")[0] : ""; const to = range?.to ? range.to.toISOString().split("T")[0] : ""; if (from || to) { params.set(`daterange_${key}`, `${from}|${to}`); } else { params.delete(`daterange_${key}`); } }); } // Number if (filters?.number) { Object.entries(filters.number).forEach(([key, value]) => { if (value !== undefined) { params.set(`num_${key}`, value.toString()); } else { params.delete(`num_${key}`); } }); } // NumberRange if (filters?.numberRange) { Object.entries(filters.numberRange).forEach(([key, range]) => { const min = range?.min !== undefined ? range.min.toString() : ""; const max = range?.max !== undefined ? range.max.toString() : ""; if (min || max) { params.set(`numrange_${key}`, `${min}|${max}`); } else { params.delete(`numrange_${key}`); } }); } return params; }