mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 11:12:04 +00:00
feat: add HTTP client, query cache and shared utilities
This commit is contained in:
parent
d07ff67a94
commit
5c8a3443f7
11 changed files with 682 additions and 0 deletions
45
src/api/api-error.ts
Normal file
45
src/api/api-error.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function mapHttpStatusToMessage(status: number, data?: unknown): string {
|
||||
if (status === 401) {
|
||||
return "You are not authorized to access this page.";
|
||||
}
|
||||
if (status === 403) {
|
||||
return "You do not have permission to perform this action.";
|
||||
}
|
||||
if (status === 404) {
|
||||
return "The requested resource was not found.";
|
||||
}
|
||||
if (status === 500) {
|
||||
return "A server error occurred. Please try again later.";
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
const record = data as Record<string, unknown>;
|
||||
const message = record.message ?? record.Message ?? record.error;
|
||||
if (typeof message === "string" && message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
return "An error occurred";
|
||||
}
|
||||
|
||||
export async function createApiErrorFromResponse(response: Response): Promise<ApiError> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = undefined;
|
||||
}
|
||||
|
||||
return new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||
}
|
||||
154
src/api/api-paths.ts
Normal file
154
src/api/api-paths.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
export const API_PATHS = {
|
||||
authentication: {
|
||||
login: "Authentication/Login",
|
||||
logout: "Authentication/Logout",
|
||||
refresh: "Authentication/Refresh",
|
||||
getCurrentUser: "Authentication/GetCurrentUser",
|
||||
changePassword: "Authentication/ChangePassword",
|
||||
forgetPassword: "Authentication/ForgetPassword",
|
||||
verificationCode: "Authentication/VerificationCode",
|
||||
resetPassword: "Authentication/ResetPassword",
|
||||
},
|
||||
workOrder: {
|
||||
getList: "WorkOrder/GetWorkOrderList",
|
||||
getById: "WorkOrder/GetById",
|
||||
create: "WorkOrder/Create",
|
||||
update: "WorkOrder/Update",
|
||||
delete: "WorkOrder/DeleteWorkOrder",
|
||||
addCommentJson: "WorkOrder/AddCommentJson",
|
||||
changeAssignment: "WorkOrder/ChangeAssignment",
|
||||
updateChecklistItem: "WorkOrder/UpdateChecklistItem",
|
||||
addChecklistItem: "WorkOrder/AddChecklistItem",
|
||||
verifyDispatch: "WorkOrder/VerifyDispatch",
|
||||
addDispatchSignoff: "WorkOrder/AddDispatchSignoff",
|
||||
getDispatch: "WorkOrder/GetDispatch",
|
||||
updateDispatch: "WorkOrder/UpdateDispatch",
|
||||
addDispatchComment: "WorkOrder/AddDispatchComment",
|
||||
dispatchToVendor: "WorkOrder/DispatchToVendor",
|
||||
changeStatus: "WorkOrder/ChangeStatus",
|
||||
},
|
||||
pmSchedule: {
|
||||
getList: "PMSchedule/GetList",
|
||||
getById: "PMSchedule/GetById",
|
||||
save: "PMSchedule/Save",
|
||||
delete: "PMSchedule/Delete",
|
||||
create: "PmSchedule/Create",
|
||||
edit: "PmSchedule/Edit",
|
||||
},
|
||||
followUp: {
|
||||
getList: "FollowUp/GetFollowUpList",
|
||||
getById: "FollowUp/GetById",
|
||||
create: "FollowUp/Create",
|
||||
update: "FollowUp/Update",
|
||||
delete: "FollowUp/Delete",
|
||||
complete: "FollowUp/Complete",
|
||||
cancel: "FollowUp/Cancel",
|
||||
save: "FollowUp/Save",
|
||||
},
|
||||
employee: {
|
||||
getList: "Employee/GetEmployeeList",
|
||||
getById: "Employee/GetById",
|
||||
create: "Employee/Create",
|
||||
update: "Employee/Update",
|
||||
delete: "Employee/Delete",
|
||||
createEmployee: "Employee/CreateEmployee",
|
||||
updateEmployee: "Employee/UpdateEmployee",
|
||||
deleteEmployee: "Employee/DeleteEmployee",
|
||||
dropdown: "Employee/Dropdown",
|
||||
},
|
||||
contact: {
|
||||
getList: "Contact/GetContactList",
|
||||
getById: "Contact/GetById",
|
||||
create: "Contact/Create",
|
||||
update: "Contact/Update",
|
||||
delete: "Contact/Delete",
|
||||
addContacts: "Contact/AddContacts",
|
||||
editContacts: "Contact/EditContacts",
|
||||
dropdown: "Contact/Dropdown",
|
||||
getLocations: "Contact/GetLocations",
|
||||
},
|
||||
location: {
|
||||
getList: "Location/GetLocationList",
|
||||
getById: "Location/GetById",
|
||||
create: "Location/Create",
|
||||
update: "Location/Update",
|
||||
delete: "Location/Delete",
|
||||
deleteLocation: "Location/DeleteLocation",
|
||||
dropdown: "Location/Dropdown",
|
||||
},
|
||||
account: {
|
||||
getList: "Account/GetAccountList",
|
||||
getById: "Account/GetById",
|
||||
create: "Account/Create",
|
||||
update: "Account/Update",
|
||||
delete: "Account/Delete",
|
||||
addAccount: "Account/AddAccount",
|
||||
editAccount: "Account/EditAccount",
|
||||
deleteAccount: "Account/DeleteAccount",
|
||||
dropdown: "Account/Dropdown",
|
||||
},
|
||||
asset: {
|
||||
getList: "Asset/GetAssetList",
|
||||
getById: "Asset/GetById",
|
||||
getAssetById: "Asset/GetAssetById",
|
||||
create: "Asset/Create",
|
||||
update: "Asset/Update",
|
||||
delete: "Asset/Delete",
|
||||
deleteAsset: "Asset/DeleteAsset",
|
||||
addAsset: "Asset/AddAsset",
|
||||
editAsset: "Asset/EditAsset",
|
||||
},
|
||||
vendor: {
|
||||
getList: "Vendor/GetVendorList",
|
||||
getById: "Vendor/GetById",
|
||||
create: "Vendor/Create",
|
||||
update: "Vendor/Update",
|
||||
delete: "Vendor/Delete",
|
||||
deleteVendor: "Vendor/DeleteVendor",
|
||||
dropdown: "Vendor/Dropdown",
|
||||
},
|
||||
calendar: {
|
||||
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
||||
getEventList: "Calendar/GetEventList",
|
||||
getEventById: "Calendar/GetEventById",
|
||||
addEvent: "Calendar/AddEvent",
|
||||
editEvent: "Calendar/EditEvent",
|
||||
deleteEvent: "Calendar/DeleteEvent",
|
||||
},
|
||||
dashboard: {
|
||||
stats: "Dashboard/Stats",
|
||||
},
|
||||
common: {
|
||||
getSitesName: "Common/GetSitesName",
|
||||
},
|
||||
dropdownOptions: {
|
||||
byCategory: "DropdownOptions/ByCategory",
|
||||
create: "DropdownOptions/Create",
|
||||
update: "DropdownOptions/Update",
|
||||
delete: "DropdownOptions",
|
||||
},
|
||||
user: {
|
||||
list: "User",
|
||||
},
|
||||
taskListTemplate: {
|
||||
list: "TaskListTemplate",
|
||||
},
|
||||
vendorPortal: {
|
||||
base: "vendor-portal",
|
||||
session: "/session",
|
||||
dispatches: "/dispatches",
|
||||
},
|
||||
rest: {
|
||||
employees: "employees",
|
||||
contacts: "contacts",
|
||||
accounts: "accounts",
|
||||
locations: "locations",
|
||||
workOrders: "workorders",
|
||||
pmSchedules: "pmschedules",
|
||||
followups: "followups",
|
||||
vendors: "vendors",
|
||||
taskListTemplates: "tasklisttemplates",
|
||||
uplifts: "uplifts",
|
||||
dispatches: "dispatches",
|
||||
},
|
||||
} as const;
|
||||
132
src/api/api.ts
Normal file
132
src/api/api.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import ky, { type HTTPError, type KyInstance, type Options } from "ky";
|
||||
import { env } from "@/lib/env";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { createApiErrorFromResponse, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import queryClient from "@/lib/query/query-client";
|
||||
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
function resolvePrefixUrl(url: string): string {
|
||||
const trimmed = url.trim() || "/api";
|
||||
|
||||
if (trimmed.startsWith("http")) {
|
||||
return trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
|
||||
}
|
||||
|
||||
const path = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
const pathWithSlash = path.endsWith("/") ? path : `${path}/`;
|
||||
const origin =
|
||||
typeof window !== "undefined" && window.location?.origin
|
||||
? window.location.origin
|
||||
: "http://localhost:3000";
|
||||
|
||||
return `${origin}${pathWithSlash}`;
|
||||
}
|
||||
|
||||
function clearSessionAndRedirectToLogin(): void {
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
|
||||
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
|
||||
window.location.replace("/login");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSession(): Promise<boolean> {
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const response = await api.post(API_PATHS.authentication.refresh, {
|
||||
retry: { limit: 0 },
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function isRefreshRequest(request: Request): boolean {
|
||||
return request.url.includes(API_PATHS.authentication.refresh);
|
||||
}
|
||||
|
||||
function isAuthSessionRequest(request: Request): boolean {
|
||||
return (
|
||||
request.url.includes(API_PATHS.authentication.getCurrentUser) ||
|
||||
request.url.includes(API_PATHS.authentication.login) ||
|
||||
request.url.includes(API_PATHS.authentication.logout)
|
||||
);
|
||||
}
|
||||
|
||||
export const api: KyInstance = ky.create({
|
||||
prefixUrl: resolvePrefixUrl(env.apiUrl),
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
hooks: {
|
||||
beforeError: [
|
||||
async (error: HTTPError) => {
|
||||
const { response } = error;
|
||||
|
||||
if (!response) {
|
||||
error.message = "No response from server. Please check your connection.";
|
||||
return error;
|
||||
}
|
||||
|
||||
const apiError = await createApiErrorFromResponse(response);
|
||||
error.message = apiError.message;
|
||||
return error;
|
||||
},
|
||||
],
|
||||
afterResponse: [
|
||||
async (request, _options, response) => {
|
||||
if (response.status !== 401 || isRefreshRequest(request) || isAuthSessionRequest(request)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const refreshed = await refreshSession();
|
||||
if (!refreshed) {
|
||||
clearSessionAndRedirectToLogin();
|
||||
return response;
|
||||
}
|
||||
|
||||
const retried = await ky(request, { credentials: "include" });
|
||||
if (retried.status === 401) {
|
||||
clearSessionAndRedirectToLogin();
|
||||
}
|
||||
|
||||
return retried;
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export async function apiGet<T>(url: string, options?: Options): Promise<T> {
|
||||
return api.get(url, options).json<T>();
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.post(url, { ...options, json: body }).json<T>();
|
||||
}
|
||||
|
||||
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.put(url, { ...options, json: body }).json<T>();
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
|
||||
return api.delete(url, options).json<T>();
|
||||
}
|
||||
|
||||
export { mapHttpStatusToMessage };
|
||||
73
src/api/handle-api-response.ts
Normal file
73
src/api/handle-api-response.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function handleApiResponse<T>(data: unknown): T {
|
||||
if (Array.isArray(data)) {
|
||||
return data as T;
|
||||
}
|
||||
|
||||
if (isRecord(data)) {
|
||||
const nested = data.data ?? data.Data ?? data.value;
|
||||
if (nested !== undefined) {
|
||||
return nested as T;
|
||||
}
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function handlePaginatedResponse<T>(
|
||||
data: unknown,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
): PaginatedResult<T> {
|
||||
const page = params?.page ?? 1;
|
||||
const pageSize = params?.pageSize ?? 12;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return {
|
||||
items: data as T[],
|
||||
totalCount: data.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRecord(data)) {
|
||||
const nested = data.data ?? data.Data ?? data.value ?? data;
|
||||
const items = Array.isArray(nested) ? (nested as T[]) : [];
|
||||
|
||||
const totalCount =
|
||||
(typeof data.totalCount === "number" ? data.totalCount : undefined) ??
|
||||
(typeof data.TotalCount === "number" ? data.TotalCount : undefined) ??
|
||||
(typeof data.Count === "number" ? data.Count : undefined) ??
|
||||
items.length;
|
||||
|
||||
return {
|
||||
items,
|
||||
totalCount,
|
||||
page:
|
||||
(typeof data.pageNumber === "number" ? data.pageNumber : undefined) ??
|
||||
(typeof data.PageNumber === "number" ? data.PageNumber : undefined) ??
|
||||
page,
|
||||
pageSize:
|
||||
(typeof data.pageSize === "number" ? data.pageSize : undefined) ??
|
||||
(typeof data.PageSize === "number" ? data.PageSize : undefined) ??
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
items: [],
|
||||
totalCount: 0,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
12
src/hooks/use-debounce.ts
Normal file
12
src/hooks/use-debounce.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delayMs = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedValue(value), delayMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
106
src/infra/query-key/query-key.ts
Normal file
106
src/infra/query-key/query-key.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
export const queryKeys = {
|
||||
auth: {
|
||||
all: ["auth"] as const,
|
||||
session: () => [...queryKeys.auth.all, "session"] as const,
|
||||
},
|
||||
workOrders: {
|
||||
all: ["workOrders"] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.workOrders.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
|
||||
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
|
||||
vendors: (trade?: string, siteZip?: string) =>
|
||||
[...queryKeys.workOrders.all, "vendors", trade ?? "", siteZip ?? ""] as const,
|
||||
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
||||
},
|
||||
pmSchedules: {
|
||||
all: ["pmSchedules"] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.pmSchedules.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.pmSchedules.all, "detail", id] as const,
|
||||
},
|
||||
accounts: {
|
||||
all: ["accounts"] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.accounts.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.accounts.all, "detail", id] as const,
|
||||
},
|
||||
employees: {
|
||||
all: ["employees"] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.employees.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.employees.all, "detail", id] as const,
|
||||
},
|
||||
dashboard: {
|
||||
all: ["dashboard"] as const,
|
||||
stats: () => [...queryKeys.dashboard.all, "stats"] as const,
|
||||
},
|
||||
dropdownOptions: {
|
||||
all: ["dropdownOptions"] as const,
|
||||
byCategory: (category: string, parent?: string) =>
|
||||
[...queryKeys.dropdownOptions.all, "byCategory", category, parent ?? ""] as const,
|
||||
},
|
||||
locations: {
|
||||
all: ["locations"] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.locations.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.locations.all, "detail", id] as const,
|
||||
dropdown: () => [...queryKeys.locations.all, "dropdown"] as const,
|
||||
},
|
||||
vendors: {
|
||||
all: ["vendors"] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.vendors.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.vendors.all, "detail", id] as const,
|
||||
portalToken: (vendorId: string | number) =>
|
||||
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
|
||||
},
|
||||
calendar: {
|
||||
all: ["calendar"] as const,
|
||||
range: (start: string, end: string) =>
|
||||
[...queryKeys.calendar.all, "range", start, end] as const,
|
||||
detail: (id: string | number) => [...queryKeys.calendar.all, "detail", id] as const,
|
||||
},
|
||||
uplifts: {
|
||||
all: ["uplifts"] as const,
|
||||
queue: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.uplifts.all, "queue", params ?? {}] as const,
|
||||
forDispatch: (dispatchId: string | number) =>
|
||||
[...queryKeys.uplifts.all, "dispatch", dispatchId] as const,
|
||||
},
|
||||
vendorPos: {
|
||||
all: ["vendorPos"] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.vendorPos.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.vendorPos.all, "detail", id] as const,
|
||||
},
|
||||
followUps: {
|
||||
all: ["followUps"] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.followUps.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.followUps.all, "detail", id] as const,
|
||||
},
|
||||
contacts: {
|
||||
all: ["contacts"] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.contacts.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.contacts.all, "detail", id] as const,
|
||||
},
|
||||
taskTemplates: {
|
||||
all: ["taskTemplates"] as const,
|
||||
},
|
||||
assets: {
|
||||
all: ["assets"] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
[...queryKeys.assets.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.assets.all, "detail", id] as const,
|
||||
sites: () => [...queryKeys.assets.all, "sites"] as const,
|
||||
},
|
||||
vendorPortal: {
|
||||
all: ["vendorPortal"] as const,
|
||||
session: (token: string) => [...queryKeys.vendorPortal.all, "session", token] as const,
|
||||
dispatches: (token: string) => [...queryKeys.vendorPortal.all, "dispatches", token] as const,
|
||||
dispatch: (token: string, id: string | number) =>
|
||||
[...queryKeys.vendorPortal.all, "dispatch", token, id] as const,
|
||||
},
|
||||
} as const;
|
||||
4
src/lib/env.ts
Normal file
4
src/lib/env.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const env = {
|
||||
apiUrl: import.meta.env.VITE_API_URL?.trim() || "/api",
|
||||
isDev: import.meta.env.DEV,
|
||||
} as const;
|
||||
33
src/lib/query/query-client.ts
Normal file
33
src/lib/query/query-client.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
console.error("Query error:", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An error occurred while fetching data";
|
||||
toast.error(message);
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error, _variables, _context, mutation) => {
|
||||
if (mutation.options.meta?.suppressErrorToast) return;
|
||||
|
||||
console.error("Mutation error:", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An error occurred while updating data";
|
||||
toast.error(message);
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default queryClient;
|
||||
10
src/lib/query/require-query-param.ts
Normal file
10
src/lib/query/require-query-param.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function requireQueryParam(
|
||||
value: string | number | undefined,
|
||||
name: string,
|
||||
): string | number {
|
||||
if (value === undefined || value === "") {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
107
src/lib/time-utils.ts
Normal file
107
src/lib/time-utils.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
export function timeSince(dateString: string): string {
|
||||
if (!dateString) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const d = new Date(dateString.endsWith("Z") ? dateString : `${dateString}Z`);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (mins < 1) {
|
||||
return "just now";
|
||||
}
|
||||
if (mins < 60) {
|
||||
return `${mins}m ago`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) {
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
return `${Math.floor(days / 7)}w ago`;
|
||||
}
|
||||
|
||||
export function timeBetween(startStr: string, endStr: string): string {
|
||||
if (!startStr || !endStr) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const start = new Date(startStr.endsWith("Z") ? startStr : `${startStr}Z`);
|
||||
const end = new Date(endStr.endsWith("Z") ? endStr : `${endStr}Z`);
|
||||
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (mins < 60) {
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
export function waitTimeColor(dateString: string): "green" | "yellow" | "red" | "" {
|
||||
if (!dateString) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const d = new Date(dateString.endsWith("Z") ? dateString : `${dateString}Z`);
|
||||
const hours = (Date.now() - d.getTime()) / 3600000;
|
||||
|
||||
if (hours < 4) {
|
||||
return "green";
|
||||
}
|
||||
if (hours < 24) {
|
||||
return "yellow";
|
||||
}
|
||||
|
||||
return "red";
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalized = value.endsWith("Z") ? value : `${value}Z`;
|
||||
const date = new Date(normalized);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const datePart = date.toLocaleDateString("en-CA");
|
||||
const timePart = date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const tz = date.toLocaleTimeString(undefined, { timeZoneName: "short" }).split(" ").pop();
|
||||
|
||||
return `${datePart} ${timePart} ${tz ?? ""}`.trim();
|
||||
}
|
||||
|
||||
export function toDateInputValue(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return value.split("T")[0] ?? value;
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue