diff --git a/src/api/api-error.ts b/src/api/api-error.ts new file mode 100644 index 00000000..2d32aa4e --- /dev/null +++ b/src/api/api-error.ts @@ -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; + 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 { + let data: unknown; + try { + data = await response.json(); + } catch { + data = undefined; + } + + return new ApiError(mapHttpStatusToMessage(response.status, data), response.status); +} diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts new file mode 100644 index 00000000..ef52f105 --- /dev/null +++ b/src/api/api-paths.ts @@ -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; diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 00000000..2c9ccaf4 --- /dev/null +++ b/src/api/api.ts @@ -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 | 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 { + 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(url: string, options?: Options): Promise { + return api.get(url, options).json(); +} + +export async function apiPost(url: string, body?: unknown, options?: Options): Promise { + return api.post(url, { ...options, json: body }).json(); +} + +export async function apiPut(url: string, body?: unknown, options?: Options): Promise { + return api.put(url, { ...options, json: body }).json(); +} + +export async function apiDelete(url: string, options?: Options): Promise { + return api.delete(url, options).json(); +} + +export { mapHttpStatusToMessage }; diff --git a/src/api/handle-api-response.ts b/src/api/handle-api-response.ts new file mode 100644 index 00000000..24ce2fc9 --- /dev/null +++ b/src/api/handle-api-response.ts @@ -0,0 +1,73 @@ +export interface PaginatedResult { + items: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function handleApiResponse(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( + data: unknown, + params?: { page?: number; pageSize?: number }, +): PaginatedResult { + 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, + }; +} diff --git a/src/hooks/use-debounce.ts b/src/hooks/use-debounce.ts new file mode 100644 index 00000000..307e70e9 --- /dev/null +++ b/src/hooks/use-debounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(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; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts new file mode 100644 index 00000000..9d14bc46 --- /dev/null +++ b/src/infra/query-key/query-key.ts @@ -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) => + [...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) => + [...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) => + [...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) => + [...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) => + [...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) => + [...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) => + [...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) => + [...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) => + [...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) => + [...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) => + [...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; diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 00000000..51b5d14a --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,4 @@ +export const env = { + apiUrl: import.meta.env.VITE_API_URL?.trim() || "/api", + isDev: import.meta.env.DEV, +} as const; diff --git a/src/lib/query/query-client.ts b/src/lib/query/query-client.ts new file mode 100644 index 00000000..1223fc2b --- /dev/null +++ b/src/lib/query/query-client.ts @@ -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; diff --git a/src/lib/query/require-query-param.ts b/src/lib/query/require-query-param.ts new file mode 100644 index 00000000..a75dee83 --- /dev/null +++ b/src/lib/query/require-query-param.ts @@ -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; +} diff --git a/src/lib/time-utils.ts b/src/lib/time-utils.ts new file mode 100644 index 00000000..a0a0e8c0 --- /dev/null +++ b/src/lib/time-utils.ts @@ -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; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 00000000..a5ef1935 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +}