mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 15:52: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