feat: add HTTP client, query cache and shared utilities

This commit is contained in:
Arthur Bassi 2026-06-12 11:44:13 -03:00
parent d07ff67a94
commit 5c8a3443f7
11 changed files with 682 additions and 0 deletions

45
src/api/api-error.ts Normal file
View 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
View 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
View 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 };

View 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
View 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;
}

View 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
View file

@ -0,0 +1,4 @@
export const env = {
apiUrl: import.meta.env.VITE_API_URL?.trim() || "/api",
isDev: import.meta.env.DEV,
} as const;

View 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;

View 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
View 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
View 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));
}