import axios from 'axios' import { withBase, stripBase } from './../utils/basePath' // BASE_URL ends in '/', so this is '/api' at root or '/ops/api' under a subpath // mount. Keeps the SPA, its API, and IIS all on the same mount path. const api = axios.create({ baseURL: import.meta.env.BASE_URL + 'api', headers: { 'Content-Type': 'application/json' } }) // Add auth token and normalize pagination params // Kiosk device tokens. The kiosk routes are the only ones that write without a // login, so each is additionally gated on a token an administrator provisioned // into THAT screen's browser - see the kiosk views, which claim it from a // ?token= URL once and strip it from the address bar. A token served inside the // page would be readable by anyone who could load the page, so it is per-device // and never shipped with the bundle. // // One entry per kiosk: the two are separate screens in separate rooms and a // shared token would mean provisioning one grants the other. export const KIOSK_TOKEN_KEYS = { '/printedparts/kiosk/': 'printedparts-kiosk-token', '/usb/kiosk/': 'usb-kiosk-token', } function kioskTokenFor(url) { const path = url || '' for (const [prefix, storageKey] of Object.entries(KIOSK_TOKEN_KEYS)) { if (path.includes(prefix)) { try { return localStorage.getItem(storageKey) || '' } catch { return '' } } } return '' } // Claim a kiosk's device token, once, from the URL an admin used to set the // screen up. Kept in THIS browser and stripped from the address bar straight // away, so it does not sit there for a shoulder to read or travel in a bookmark // or a screenshot. export function claimKioskToken(storageKey) { try { const url = new URL(window.location.href) const token = (url.searchParams.get('token') || '').trim() if (!token) return localStorage.setItem(storageKey, token) url.searchParams.delete('token') window.history.replaceState({}, '', url.pathname + url.search + url.hash) } catch { // A browser refusing localStorage cannot be provisioned. Nothing to // recover; the routes 404 and the operator sees the standard error. } } api.interceptors.request.use(config => { const token = kioskTokenFor(config.url) if (token) config.headers['X-Kiosk-Token'] = token return config }) api.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } // `per_page` used to be sent alongside `perpage` for backend compatibility. // get_pagination_params reads `perpage` and only falls back to `per_page`, and // this client always sends `perpage`, so the duplicate never did anything - // it just made every list URL carry a redundant parameter. // // Dropping it also has a one-off benefit worth recording. The endpoints list // route used to redirect (308, PERMANENT and therefore cached by the browser // against the exact URL including its query string). Clients that hit the old // route hold that rewrite even after the server is fixed: the request never // leaves the machine, so nothing deployed can clear it. Changing the query // string changes the cache key, so those clients miss the poisoned entry and // reach the corrected route. return config }) // Handle 401 errors (token expired) - only redirect if user was logged in api.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { const hadToken = localStorage.getItem('token') localStorage.removeItem('token') localStorage.removeItem('user') // Only redirect if user was previously logged in (session expired). // Preserve the destination so login returns the user to this page. if (hadToken) { const loginPath = withBase('/login') // Router paths exclude the mount base; strip it or login's // router.push double-prefixes under a subpath mount. const here = stripBase(window.location.pathname) + window.location.search const target = here && here !== '/login' ? loginPath + '?redirect=' + encodeURIComponent(here) : loginPath window.location.href = target } } return Promise.reject(error) } ) export default api // The backend clamps perpage to MAX_PAGE_SIZE (100) and says nothing about it, // so asking for `perpage: 1000` silently returns the first 100 rows and drops // the rest. A picker built that way looks complete and is not: with 126 // applications on a live site, the 26 sorting last were simply unselectable. // // Use this wherever a control needs the WHOLE list (dropdowns, pickers, label // batches) rather than a page of it. Returns the full array directly, not an // axios response. Anything that renders a paged table should keep calling // list() with a real page number instead. export async function fetchAllPages(path, params = {}) { const first = await api.get(path, { params: { ...params, perpage: 100, page: 1 } }) let items = first.data.data || [] const totalpages = first.data.meta?.pagination?.totalpages || 1 if (totalpages > 1) { const rest = await Promise.all( Array.from({ length: totalpages - 1 }, (_, i) => api.get(path, { params: { ...params, perpage: 100, page: i + 2 } }) ) ) rest.forEach(response => { items = items.concat(response.data.data || []) }) } return items } // Auth API export const authApi = { login(username, password) { return api.post('/auth/login', { username, password }) }, logout() { return api.post('/auth/logout') }, me() { return api.get('/auth/me') }, refresh() { const refreshToken = localStorage.getItem('refreshToken') return api.post('/auth/refresh', {}, { headers: { Authorization: `Bearer ${refreshToken}` } }) }, changePassword(payload) { return api.post('/auth/change-password', payload) } } // Machines API (plugin) export const machinesApi = { list(params = {}) { return api.get('/machines', { params }) }, // Every machine, paged past the backend's 100-row cap. Batch label printing // and "pick any record" dropdowns must use this: list() with a large // perpage is clamped to 100 and still returns a success response, so // the tail simply goes missing. listAll(params = {}) { return fetchAllPages('/machines', params) }, get(id) { return api.get(`/machines/${id}`) }, getByAsset(assetId) { return api.get(`/machines/by-asset/${assetId}`) }, create(data) { return api.post('/machines', data) }, update(id, data) { return api.put(`/machines/${id}`, data) }, delete(id) { return api.delete(`/machines/${id}`) }, dashboardSummary() { return api.get('/machines/dashboard/summary') }, // Machine types types: { list(params = {}) { return api.get('/machines/types', { params }) }, get(id) { return api.get(`/machines/types/${id}`) }, create(data) { return api.post('/machines/types', data) }, update(id, data) { return api.put(`/machines/types/${id}`, data) }, remove(id) { return api.delete(`/machines/types/${id}`) } } } // Endpoints API (plugin) - fleet telemetry read surface export const endpointsApi = { dashboardSummary() { return api.get('/endpoints/dashboard/summary') }, dashboardStability() { return api.get('/endpoints/dashboard/stability') }, list(params = {}) { return api.get('/endpoints', { params }) }, get(assetId) { return api.get(`/endpoints/${assetId}`) }, enqueueCommand(assetId, action, target = null) { const body = { action } if (target) body.target = target return api.post(`/endpoints/${assetId}/commands`, body) }, listCommands(assetId) { return api.get(`/endpoints/${assetId}/commands`) }, telemetryMeta(assetId) { return api.get(`/endpoints/asset/${assetId}/telemetrymeta`) }, // Monitor catalog: reusable process/service definitions. Shipped rows are // product-owned and read-only; local rows are user-built on the Settings page. monitors: { list() { return api.get('/endpoints/monitors') }, create(data) { return api.post('/endpoints/monitors', data) }, update(monitorid, data) { return api.put(`/endpoints/monitors/${monitorid}`, data) }, remove(monitorid) { return api.delete(`/endpoints/monitors/${monitorid}`) }, resolve(pctype) { return api.get('/endpoints/monitors/resolve', { params: { pctype } }) } } } // Computers API (plugin) export const computersApi = { list(params = {}) { return api.get('/computers', { params }) }, // Every PC, paged past the backend's 100-row cap. Batch label printing // and "pick any record" dropdowns must use this: list() with a large // perpage is clamped to 100 and still returns a success response, so // the tail simply goes missing. listAll(params = {}) { return fetchAllPages('/computers', params) }, displayKiosks() { return api.get('/computers/display-kiosks') }, get(id) { return api.get(`/computers/${id}`) }, getByAsset(assetId) { return api.get(`/computers/by-asset/${assetId}`) }, getByHostname(hostname) { return api.get(`/computers/by-hostname/${hostname}`) }, create(data) { return api.post('/computers', data) }, update(id, data) { return api.put(`/computers/${id}`, data) }, delete(id) { return api.delete(`/computers/${id}`) }, dashboardSummary() { return api.get('/computers/dashboard/summary') }, // Computer types types: { list(params = {}) { return api.get('/computers/types', { params }) }, // Every type, for a dropdown. perpage is capped at 100 server-side, so // asking for 200 returns 100 and silently truncates the list. listAll(params = {}) { return fetchAllPages('/computers/types', params) }, get(id) { return api.get(`/computers/types/${id}`) }, create(data) { return api.post('/computers/types', data) }, update(id, data) { return api.put(`/computers/types/${id}`, data) }, remove(id) { return api.delete(`/computers/types/${id}`) } }, // Remote-access protocol catalog protocols: { list(params = {}) { return api.get('/computers/protocols', { params }) }, create(data) { return api.post('/computers/protocols', data) }, update(id, data) { return api.put(`/computers/protocols/${id}`, data) }, remove(id) { return api.delete(`/computers/protocols/${id}`) } }, // Configuration/Business policies (RMM-style). Gated server-side by the // computers_configpolicies_enabled setting; routes return 404 while off. policies: { actions() { return api.get('/computers/policies/actions') }, effective(assetid) { return api.get(`/computers/policies/effective/${assetid}`) }, configuration: { list() { return api.get('/computers/policies/configuration') }, get(id) { return api.get(`/computers/policies/configuration/${id}`) }, create(data) { return api.post('/computers/policies/configuration', data) }, update(id, data) { return api.put(`/computers/policies/configuration/${id}`, data) }, remove(id) { return api.delete(`/computers/policies/configuration/${id}`) } }, business: { list() { return api.get('/computers/policies/business') }, get(id) { return api.get(`/computers/policies/business/${id}`) }, create(data) { return api.post('/computers/policies/business', data) }, update(id, data) { return api.put(`/computers/policies/business/${id}`, data) }, remove(id) { return api.delete(`/computers/policies/business/${id}`) } } } } // Relationship Types API export const relationshipTypesApi = { list() { return api.get('/assets/relationshiptypes') }, create(data) { return api.post('/assets/relationshiptypes', data) }, update(id, data) { return api.put(`/assets/relationshiptypes/${id}`, data) }, remove(id) { return api.delete(`/assets/relationshiptypes/${id}`) } } // Model Types API (the vendor models catalog: modeltypeid) export const modeltypesApi = { list(params = {}) { return api.get('/modeltypes', { params }) }, create(data) { return api.post('/modeltypes', data) }, update(id, data) { return api.put(`/modeltypes/${id}`, data) }, delete(id) { return api.delete(`/modeltypes/${id}`) } } // Vendors API export const vendorsApi = { list(params = {}) { return api.get('/vendors', { params }) }, // Every vendor, paged. perpage is capped at 100 server-side, so a picker // asking for 500 silently showed the first 100 and dropped the rest - with a // 200, so nothing looked wrong. listAll(params = {}) { return fetchAllPages('/vendors', params) }, get(id) { return api.get(`/vendors/${id}`) }, create(data) { return api.post('/vendors', data) }, update(id, data) { return api.put(`/vendors/${id}`, data) }, delete(id) { return api.delete(`/vendors/${id}`) } } // Locations API export const locationsApi = { list(params = {}) { return api.get('/locations', { params }) }, // Every location, paged past the backend's 100-row cap. Location selector // dropdowns must use this: list() with a large perpage is clamped to 100 and // still returns a success response, so the tail simply goes missing. listAll(params = {}) { return fetchAllPages('/locations', params) }, get(id) { return api.get(`/locations/${id}`) }, create(data) { return api.post('/locations', data) }, update(id, data) { return api.put(`/locations/${id}`, data) }, delete(id) { return api.delete(`/locations/${id}`) }, types: { list(params = {}) { return api.get('/locations/types', { params }) }, create(data) { return api.post('/locations/types', data) }, update(id, data) { return api.put(`/locations/types/${id}`, data) }, remove(id) { return api.delete(`/locations/types/${id}`) } } } // Printers API export const printersApi = { list(params = {}) { return api.get('/printers', { params }) }, // Printer assignment for one asset - a MACHINE normally, since the assignment // belongs to the bay and reaches whichever PC controls it. Reads and writes // that asset's OWN rows: the resolved view (own, else inherited) is // /printers/for-host, which the client uses, not the editor. assignment: { get(assetid) { return api.get(`/printers/assignments/for-asset/${assetid}`) }, // The WHOLE set in one call. A row-at-a-time edit is a non-atomic // reconcile: an HTTP failure part way leaves an asset half-assigned, with // nothing recording what was meant. set(assetid, printerassetids, defaultprinterassetid) { return api.put(`/printers/assignments/for-asset/${assetid}`, { printerassetids, defaultprinterassetid: defaultprinterassetid ?? null }) } }, // Every printer, paged past the backend's 100-row cap. Batch label printing // and "pick any record" dropdowns must use this: list() with a large // perpage is clamped to 100 and still returns a success response, so // the tail simply goes missing. listAll(params = {}) { return fetchAllPages('/printers', params) }, get(id) { return api.get(`/printers/${id}`) }, // create/update write asset core + printer extension in one call (the // printers plugin owns both). Use these instead of the legacy machinesApi. create(data) { return api.post('/printers', data) }, update(id, data) { return api.put(`/printers/${id}`, data) }, // Soft delete: the server deactivates the asset, so the printer leaves every // list while its history and relationships stay intact. remove(id) { return api.delete(`/printers/${id}`) }, updateExtension(id, data) { return api.put(`/printers/${id}/printerdata`, data) }, // printer sub-types (Laser, Inkjet, Label, Card, Wide Format, ...) types: { list(params = {}) { return api.get('/printers/types', { params }) }, create(data) { return api.post('/printers/types', data) }, update(id, data) { return api.put(`/printers/types/${id}`, data) }, remove(id) { return api.delete(`/printers/types/${id}`) } }, updateCommunication(id, data) { return api.put(`/printers/${id}/communication`, data) }, getSupplies(id) { return api.get(`/printers/${id}/supplies`) }, getDrivers(id) { return api.get(`/printers/${id}/drivers`) }, supplyForecast(days = 90) { return api.get('/printers/supplies/forecast', { params: { days } }) }, lowSupplies() { return api.get('/printers/lowsupplies') }, refreshSupplies() { return api.post('/printers/supplies/refresh') }, lookup({ ip, fqdn } = {}) { return api.get('/printers/lookup', { params: { ip, fqdn } }) }, dashboardSummary() { return api.get('/printers/dashboard/summary') }, // Flat network-printer list (with mapx/mapy) for the installer map. installList() { return api.get('/printers/install-list') }, drivers: { list(params = {}) { return api.get('/printers/drivers', { params }) }, create(data) { return api.post('/printers/drivers', data) }, update(id, data) { return api.put(`/printers/drivers/${id}`, data) }, delete(id) { return api.delete(`/printers/drivers/${id}`) } }, supplyTypes: { list() { return api.get('/printers/supplytypes') }, create(data) { return api.post('/printers/supplytypes', data) } }, // model -> toner/drum/waste part-number management modelSupplies: { meta() { return api.get('/printers/supplies/meta') }, listModels(params = {}) { return api.get('/printers/models', { params }) }, list(modelnumberid) { return api.get(`/printers/models/${modelnumberid}/supplies`) }, create(modelnumberid, data) { return api.post(`/printers/models/${modelnumberid}/supplies`, data) }, update(modelsupplyid, data) { return api.put(`/printers/supplies/${modelsupplyid}`, data) }, delete(modelsupplyid) { return api.delete(`/printers/supplies/${modelsupplyid}`) } } } // Dashboard API export const dashboardApi = { summary() { return api.get('/dashboard/summary') }, navigation() { return api.get('/dashboard/navigation') }, capabilities() { return api.get('/dashboard/capabilities') } } // Models API export const modelsApi = { list(params = {}) { return api.get('/models', { params }) }, // Backend caps perpage at 100, so page through every model. Returns the // full array directly (not an axios response). Use in forms whose model // dropdown must include the editing record's model regardless of page. listAll(params = {}) { return fetchAllPages('/models', params) }, get(id) { return api.get(`/models/${id}`) }, create(data) { return api.post('/models', data) }, update(id, data) { return api.put(`/models/${id}`, data) }, delete(id) { return api.delete(`/models/${id}`) }, uploadImage(id, file) { // multipart photo upload; backend sets imageurl to the served URL const form = new FormData() form.append('file', file) return api.post(`/models/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) }, removeImage(id) { return api.delete(`/models/${id}/image`) } } // Operating Systems API export const operatingsystemsApi = { list(params = {}) { return api.get('/operatingsystems', { params }) }, get(id) { return api.get(`/operatingsystems/${id}`) }, create(data) { return api.post('/operatingsystems', data) }, update(id, data) { return api.put(`/operatingsystems/${id}`, data) }, delete(id) { return api.delete(`/operatingsystems/${id}`) } } // Business Units API export const businessunitsApi = { list(params = {}) { return api.get('/businessunits', { params }) }, // See vendorsApi.listAll: the cap is 100 whatever the caller asks for. listAll(params = {}) { return fetchAllPages('/businessunits', params) }, get(id) { return api.get(`/businessunits/${id}`) }, create(data) { return api.post('/businessunits', data) }, update(id, data) { return api.put(`/businessunits/${id}`, data) }, delete(id) { return api.delete(`/businessunits/${id}`) } } // Applications API export const applicationsApi = { list(params = {}) { return api.get('/applications', { params }) }, // Every application, paged past the backend's 100-row cap. The catalogue is // already over 100 entries on a live site, so any picker offering "all // applications" must use this and not list({ perpage: }). listAll(params = {}) { return fetchAllPages('/applications', params) }, get(id) { return api.get(`/applications/${id}`) }, create(data) { return api.post('/applications', data) }, update(id, data) { return api.put(`/applications/${id}`, data) }, delete(id) { return api.delete(`/applications/${id}`) }, // multipart image upload; backend sets image to the served URL uploadImage(id, file) { const form = new FormData() form.append('file', file) return api.post(`/applications/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) }, removeImage(id) { return api.delete(`/applications/${id}/image`) }, // installer upload; backend sets installpath to the download URL. // onProgress gets 0-100 - an installer is big enough that a silent wait // reads as a hang. uploadPackage(id, file, onProgress) { const form = new FormData() form.append('file', file) return api.post(`/applications/${id}/package`, form, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: event => { if (onProgress && event.total) { onProgress(Math.round((event.loaded * 100) / event.total)) } } }) }, removePackage(id) { return api.delete(`/applications/${id}/package`) }, // Versions getVersions(appId) { return api.get(`/applications/${appId}/versions`) }, createVersion(appId, data) { return api.post(`/applications/${appId}/versions`, data) }, // Get PCs that have this app installed getInstalledOn(appId) { return api.get(`/applications/${appId}/installed`) }, // Machine applications (installed apps) getMachineApps(machineId) { return api.get(`/applications/machines/${machineId}`) }, installApp(machineId, data) { return api.post(`/applications/machines/${machineId}`, data) }, uninstallApp(machineId, appId) { return api.delete(`/applications/machines/${machineId}/${appId}`) }, updateInstalledApp(machineId, appId, data) { return api.put(`/applications/machines/${machineId}/${appId}`, data) } } // ShopDB-hosted script library. Backs run_script delivery: the agent fetches a // script body by digest with its collector credential and runs it in the // script's configured run context (system or user). export const scriptsApi = { list(params = {}) { return api.get('/scripts', { params }) }, listAll(params = {}) { return fetchAllPages('/scripts', params) }, get(id) { return api.get(`/scripts/${id}`) }, create(data) { return api.post('/scripts', data) }, update(id, data) { return api.put(`/scripts/${id}`, data) }, delete(id) { return api.delete(`/scripts/${id}`) }, // script-body upload; backend sets filename/sha256 to the stored payload. uploadBody(id, file, onProgress) { const form = new FormData() form.append('file', file) return api.post(`/scripts/${id}/body`, form, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: event => { if (onProgress && event.total) { onProgress(Math.round((event.loaded * 100) / event.total)) } } }) } } // Support Teams API (teams + nested contacts) export const supportteamsApi = { list(params = {}) { return api.get('/supportteams', { params }) }, get(id) { return api.get(`/supportteams/${id}`) }, create(data) { return api.post('/supportteams', data) }, update(id, data) { return api.put(`/supportteams/${id}`, data) }, remove(id) { return api.delete(`/supportteams/${id}`) }, contacts: { add(teamId, data) { return api.post(`/supportteams/${teamId}/contacts`, data) }, update(teamId, contactId, data) { return api.put(`/supportteams/${teamId}/contacts/${contactId}`, data) }, remove(teamId, contactId) { return api.delete(`/supportteams/${teamId}/contacts/${contactId}`) } } } // Search API export const searchApi = { search(query, params = {}) { return api.get('/search', { params: { q: query, ...params } }) } } // Knowledge Base API export const knowledgebaseApi = { list(params = {}) { return api.get('/knowledgebase', { params }) }, get(id) { return api.get(`/knowledgebase/${id}`) }, create(data) { return api.post('/knowledgebase', data) }, update(id, data) { return api.put(`/knowledgebase/${id}`, data) }, delete(id) { return api.delete(`/knowledgebase/${id}`) }, trackClick(id) { return api.post(`/knowledgebase/${id}/click`) }, getStats() { return api.get('/knowledgebase/stats') } } // Assets API (unified) export const assetsApi = { list(params = {}) { return api.get('/assets', { params }) }, get(id) { return api.get(`/assets/${id}`) }, create(data) { return api.post('/assets', data) }, update(id, data) { return api.put(`/assets/${id}`, data) }, delete(id) { return api.delete(`/assets/${id}`) }, getMap(params = {}) { return api.get('/assets/map', { params }) }, // Relationships getRelationships(id) { return api.get(`/assets/${id}/relationships`) }, createRelationship(data) { return api.post('/assets/relationships', data) }, deleteRelationship(relationshipId) { return api.delete(`/assets/relationships/${relationshipId}`) }, // Search assets (for relationship picker) search(query, params = {}) { return api.get('/assets', { params: { search: query, ...params } }) }, // Lookup asset by asset/machine number lookup(assetnumber) { return api.get(`/assets/lookup/${encodeURIComponent(assetnumber)}`) }, types: { list() { return api.get('/assets/types') }, get(id) { return api.get(`/assets/types/${id}`) }, update(id, data) { return api.put(`/assets/types/${id}`, data) } }, statuses: { list(params = {}) { return api.get('/assets/statuses', { params }) }, create(data) { return api.post('/assets/statuses', data) }, update(id, data) { return api.put(`/assets/statuses/${id}`, data) }, delete(id) { return api.delete(`/assets/statuses/${id}`) } } } // Notifications API export const notificationsApi = { list(params = {}) { return api.get('/notifications', { params }) }, get(id) { return api.get(`/notifications/${id}`) }, create(data) { return api.post('/notifications', data) }, update(id, data) { return api.put(`/notifications/${id}`, data) }, delete(id) { return api.delete(`/notifications/${id}`) }, getActive() { return api.get('/notifications/active') }, getCalendar(params = {}) { return api.get('/notifications/calendar', { params }) }, dashboardSummary() { return api.get('/notifications/dashboard/summary') }, getShopfloor(params = {}) { return api.get('/notifications/shopfloor', { params }) }, getEmployeeRecognitions(sso) { return api.get(`/notifications/employee/${sso}`) }, types: { list() { return api.get('/notifications/types') }, get(id) { return api.get(`/notifications/types/${id}`) }, create(data) { return api.post('/notifications/types', data) }, update(id, data) { return api.put(`/notifications/types/${id}`, data) } } } // USB Devices API export const usbApi = { list(params = {}) { return api.get('/usb', { params }) }, // Every USB device, paged past the backend's 100-row cap. Batch label printing // and "pick any record" dropdowns must use this: list() with a large // perpage is clamped to 100 and still returns a success response, so // the tail simply goes missing. listAll(params = {}) { return fetchAllPages('/usb', params) }, get(id) { return api.get(`/usb/${id}`) }, create(data) { return api.post('/usb', data) }, update(id, data) { return api.put(`/usb/${id}`, data) }, checkout(id, data) { return api.post(`/usb/${id}/checkout`, data) }, checkin(id, data = {}) { return api.post(`/usb/${id}/checkin`, data) }, getHistory(id, params = {}) { return api.get(`/usb/${id}/history`, { params }) }, // check-out log rows for one badge; activeonly = only currently-held devices getUserCheckouts(badge, activeonly = true) { const path = activeonly ? '/usb/checkouts/active' : '/usb/checkouts' return api.get(path, { params: { badge } }) } } // Reports API export const reportsApi = { list() { return api.get('/reports') }, machinesByType(params = {}) { return api.get('/reports/machines-by-type', { params }) }, assetsByStatus(params = {}) { return api.get('/reports/assets-by-status', { params }) }, kbPopularity(params = {}) { return api.get('/reports/kb-popularity', { params }) }, softwareCompliance(params = {}) { return api.get('/reports/software-compliance', { params }) }, assetInventory(params = {}) { return api.get('/reports/asset-inventory', { params }) }, pcRelationships(params = {}) { return api.get('/reports/pc-relationships', { params }) }, // On-demand report delivery: email the given rows as an HTML table. // Recipients default to the site Alert Recipients when `to` is omitted. email(payload) { return api.post('/reports/email', payload) } } // Employees API (wjf_employees database) export const employeesApi = { search(query, limit = 10) { return api.get('/employees/search', { params: { q: query, limit } }) }, lookup(sso) { return api.get(`/employees/lookup/${sso}`) }, lookupMultiple(ssoList) { return api.get('/employees/lookup', { params: { sso: ssoList } }) }, // Self-hosted directory management (directory_mode=selfhosted) directory: { list() { return api.get('/employees/directory') }, create(data) { return api.post('/employees/directory', data) }, update(sso, data) { return api.put(`/employees/directory/${sso}`, data) }, remove(sso) { return api.delete(`/employees/directory/${sso}`) }, importCsv(csv) { return api.post('/employees/directory/import', { csv }) }, // multipart photo upload; backend sets photofilename + returns photourl uploadPhoto(sso, file) { const form = new FormData() form.append('file', file) return api.post(`/employees/${sso}/photo`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) }, removePhoto(sso) { return api.delete(`/employees/${sso}/photo`) } } } // Alias for different casing export const businessUnitsApi = businessunitsApi // Dashboard defaults: visitor-IP -> business-unit mapping for kiosks export const dashboardDefaultsApi = { list() { return api.get('/dashboarddefaults') }, create(data) { return api.post('/dashboarddefaults', data) }, update(id, data) { return api.put(`/dashboarddefaults/${id}`, data) }, delete(id) { return api.delete(`/dashboarddefaults/${id}`) }, // Resolve the calling display's business unit by its IP visitorLocation() { return api.get('/dashboarddefaults/visitor-location') } } // System Settings API export const pluginsApi = { list() { return api.get('/plugins') }, // Flat array of enabled plugin names. jwt-optional, safe for kiosk routes. enabled() { return api.get('/plugins/enabled') }, setEnabled(name, enabled) { return api.put(`/plugins/${name}`, { enabled }) } } export const setupApi = { needsAdmin() { return api.get('/setup/needs-admin') }, createAdmin(data) { return api.post('/setup/create-admin', data) }, seedReference() { return api.post('/setup/seed-reference') }, seedStarter() { return api.post('/setup/seed-starter') }, complete() { return api.post('/setup/complete') } } // Buildings and the levels within them (ADR-017). Reads are public - the // printer installer map draws a blueprint before anyone logs in. export const mapLevelsApi = { list() { return api.get('/maplevels') }, get(levelid) { return api.get(`/maplevels/${levelid}`) }, createBuilding(payload) { return api.post('/maplevels/buildings', payload) }, updateBuilding(buildingid, payload) { return api.patch(`/maplevels/buildings/${buildingid}`, payload) }, create(payload) { return api.post('/maplevels', payload) }, update(levelid, payload) { return api.patch(`/maplevels/${levelid}`, payload) }, remove(levelid) { return api.delete(`/maplevels/${levelid}`) }, // Returns the image's real pixel size alongside the stored dimensions. On an // empty level the server adopts them; on a populated one it refuses and says // so, because changing the coordinate space moves every marker on it. uploadBlueprint(levelid, theme, file) { const form = new FormData() form.append('file', file) form.append('theme', theme) return api.post(`/maplevels/${levelid}/blueprint`, form, { headers: { 'Content-Type': 'multipart/form-data' }, }) }, } // Bulk marker positions: the landmark transform, bulk placement, review state // and undo. Every write snapshots first. export const mapPositionsApi = { setPositions(positions, verified = true) { return api.post('/mappositions/positions', { positions, verified }) }, transform(payload) { return api.post('/mappositions/transform', payload) }, verify(assetids, unverify = false) { return api.post('/mappositions/verify', { assetids, unverify }) }, snapshots() { return api.get('/mappositions/snapshots') }, restore(snapshotid) { return api.post(`/mappositions/snapshots/${snapshotid}/restore`) }, } export const settingsApi = { list(params = {}) { return api.get('/settings', { params }) }, get(key) { return api.get(`/settings/${key}`) }, update(key, value) { return api.put(`/settings/${key}`, { value }) }, testEmail(to) { return api.post('/settings/test-email', { to }) }, create(data) { return api.post('/settings', data) }, uploadMapBlueprint(theme, file) { const form = new FormData() form.append('theme', theme) form.append('file', file) return api.post('/settings/map-blueprint', form, { headers: { 'Content-Type': 'multipart/form-data' } }) }, uploadBrandingLogo(kind, file) { // kind is one of site/qr/badge/favicon; backend writes the matching setting const form = new FormData() form.append('kind', kind) form.append('file', file) return api.post('/settings/branding-logo', form, { headers: { 'Content-Type': 'multipart/form-data' } }) }, downloadCollectorScript() { // Collect-ShopDB.ps1 with this site's CSV path baked into $NetworkStoreRoot return api.get('/settings/asset-import/script', { responseType: 'blob' }) }, listImportTemplates() { // Importable CSV tables in the order they must be uploaded return api.get('/settings/asset-import/templates') }, downloadImportTemplate(tablename) { // Generated CSV template (header + example row) for one table return api.get(`/settings/asset-import/templates/${encodeURIComponent(tablename)}`, { responseType: 'blob' }) }, importAssetCsvs(files, commit = false, options = {}) { // Multipart upload of one or more CSVs. commit=false is a dry-run preview // that writes nothing; commit=true applies the whole set in one transaction. // options.source picks a foreign adapter (azure, thor); default native means // the files are already canonical per-table CSVs. options.computertype is // stamped onto every imported computer when a source assigns a PC type. const form = new FormData() for (const file of files) { form.append('files', file) } form.append('commit', commit ? 'true' : 'false') if (options.source) { form.append('source', options.source) } if (options.computertype) { form.append('computertype', options.computertype) } if (options.category) { form.append('category', options.category) } return api.post('/settings/asset-import/import', form, { headers: { 'Content-Type': 'multipart/form-data' } }) }, wincPreview(file) { // Read-only WINC (Maximo-style) machine-adoption analysis. Uploads one CSV // and returns the approval packet (counts, needs-create business units, // unresolved values); writes nothing. const form = new FormData() form.append('files', file) return api.post('/settings/asset-import/winc/preview', form, { headers: { 'Content-Type': 'multipart/form-data' } }) }, wincCommit(file, acknowledge = [], planhash = '') { // Apply a WINC plan reversibly. acknowledge is the list of needs-create // group names (e.g. ['businessunit']) the admin confirmed. planhash is the // one the PREVIEW returned - the server rejects a commit without it, // because otherwise "approved" means only that these group names were // ticked, and the plan applied could be a different one. const form = new FormData() form.append('files', file) if (acknowledge.length) { form.append('acknowledge', acknowledge.join(',')) } if (planhash) { form.append('planhash', planhash) } return api.post('/settings/asset-import/winc/commit', form, { headers: { 'Content-Type': 'multipart/form-data' } }) }, wincBatches() { // Applied/undone WINC import batches, newest first, for the undo list. return api.get('/settings/asset-import/winc/batches') }, wincUndo(batchid) { // Reverse an applied WINC import batch by id. return api.post('/settings/asset-import/winc/undo', { batchid }) } } // Slide manager (lobby display + shopfloor screensaver) export const slidesApi = { list(surface) { return api.get(`/slides/${surface}`) }, upload(surface, formData) { return api.post(`/slides/${surface}/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }) }, reorder(surface, order) { return api.post(`/slides/${surface}/order`, { order }) }, remove(surface, files) { return api.post(`/slides/${surface}/delete`, { files }) }, // Per-slide hold time. The route has always existed; nothing called it, so // the documented per-slide seconds could only be set with curl. update(surface, slideid, data) { return api.patch(`/slides/${surface}/${slideid}`, data) } } // Audit Logs API export const auditLogsApi = { list(params = {}) { return api.get('/auditlogs', { params }) }, getEntityHistory(entitytype, entityid) { return api.get(`/auditlogs/entity/${entitytype}/${entityid}`) }, getStats() { return api.get('/auditlogs/stats') } } // Users API export const usersApi = { list() { return api.get('/users') }, get(id) { return api.get(`/users/${id}`) }, create(data) { return api.post('/users', data) }, update(id, data) { return api.put(`/users/${id}`, data) }, delete(id) { return api.delete(`/users/${id}`) }, // Permissions permissions: { list() { return api.get('/users/permissions') } }, // Roles roles: { list() { return api.get('/users/roles') }, create(data) { return api.post('/users/roles', data) }, update(id, data) { return api.put(`/users/roles/${id}`, data) }, delete(id) { return api.delete(`/users/roles/${id}`) } } } // Personal API tokens: authenticate scripts/integrations as a user without // the hourly-expiring login JWT. The secret is returned ONCE, on create. export const apitokensApi = { // all=true (admin) lists everyone's tokens; otherwise just the caller's. list(params = {}) { return api.get('/apitokens', { params }) }, create(data) { return api.post('/apitokens', data) }, update(id, data) { return api.put(`/apitokens/${id}`, data) }, remove(id) { return api.delete(`/apitokens/${id}`) } } // Network API (devices, subnets, and VLANs) export const networkApi = { // Network devices list(params = {}) { return api.get('/network', { params }) }, // Every network device, paged past the backend's 100-row cap. Batch label // printing and "pick any record" dropdowns must use this: list() with a // large perpage is clamped to 100 and still returns a success response, so // the tail simply goes missing. listAll(params = {}) { return fetchAllPages('/network', params) }, get(id) { return api.get(`/network/${id}`) }, getByAsset(assetId) { return api.get(`/network/by-asset/${assetId}`) }, getByHostname(hostname) { return api.get(`/network/by-hostname/${hostname}`) }, create(data) { return api.post('/network', data) }, update(id, data) { return api.put(`/network/${id}`, data) }, delete(id) { return api.delete(`/network/${id}`) }, dashboardSummary() { return api.get('/network/dashboard/summary') }, // Network device types types: { list(params = {}) { return api.get('/network/types', { params }) }, get(id) { return api.get(`/network/types/${id}`) }, create(data) { return api.post('/network/types', data) }, update(id, data) { return api.put(`/network/types/${id}`, data) }, remove(id) { return api.delete(`/network/types/${id}`) } }, // VLANs vlans: { list(params = {}) { return api.get('/network/vlans', { params }) }, get(id) { return api.get(`/network/vlans/${id}`) }, create(data) { return api.post('/network/vlans', data) }, update(id, data) { return api.put(`/network/vlans/${id}`, data) }, delete(id) { return api.delete(`/network/vlans/${id}`) } }, // Subnets subnets: { list(params = {}) { return api.get('/network/subnets', { params }) }, get(id) { return api.get(`/network/subnets/${id}`) }, create(data) { return api.post('/network/subnets', data) }, update(id, data) { return api.put(`/network/subnets/${id}`, data) }, delete(id) { return api.delete(`/network/subnets/${id}`) } } } // Custom fields: site-defined attributes per asset type. export const customFieldsApi = { // Definitions list(params = {}) { return api.get('/customfields', { params }) }, create(data) { return api.post('/customfields', data) }, update(fieldid, data) { return api.put(`/customfields/${fieldid}`, data) }, remove(fieldid) { return api.delete(`/customfields/${fieldid}`) }, // Per-asset values (defs merged with the asset's stored values) forAsset(assetid) { return api.get(`/customfields/asset/${assetid}`) }, saveForAsset(assetid, values) { return api.put(`/customfields/asset/${assetid}`, { values }) } } // Warranty plugin: asset warranty tracking (manual + provider lookups). export const warrantyApi = { list(params = {}) { return api.get('/warranty', { params }) }, get(id) { return api.get(`/warranty/${id}`) }, forAsset(assetid) { return api.get(`/warranty/asset/${assetid}`) }, create(data) { return api.post('/warranty', data) }, update(id, data) { return api.put(`/warranty/${id}`, data) }, remove(id) { return api.delete(`/warranty/${id}`) }, refresh(id) { return api.post(`/warranty/${id}/refresh`) }, syncDell(all = false) { return api.post('/warranty/sync/dell', null, { params: all ? { all: 'true' } : {} }) }, report() { return api.get('/warranty/report') }, // proof of cover: invoice, certificate, whatever the vendor sent uploadProof(id, file) { const form = new FormData() form.append('file', file) return api.post(`/warranty/${id}/proof`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) }, removeProof(id) { return api.delete(`/warranty/${id}/proof`) } } // Measuring tools API (plugin) export const measuringtoolsApi = { list(params = {}) { return api.get('/measuringtools', { params }) }, // Every measuring tool, paged past the backend's 100-row cap. Batch label printing // and "pick any record" dropdowns must use this: list() with a large // perpage is clamped to 100 and still returns a success response, so // the tail simply goes missing. listAll(params = {}) { return fetchAllPages('/measuringtools', params) }, get(id) { return api.get(`/measuringtools/${id}`) }, getByAsset(assetid) { return api.get(`/measuringtools/by-asset/${assetid}`) }, create(data) { return api.post('/measuringtools', data) }, update(id, data) { return api.put(`/measuringtools/${id}`, data) }, remove(id) { return api.delete(`/measuringtools/${id}`) }, calibrationReport() { return api.get('/measuringtools/report/calibration') }, // Measuring-tool types types: { list(params = {}) { return api.get('/measuringtools/types', { params }) }, get(id) { return api.get(`/measuringtools/types/${id}`) }, create(data) { return api.post('/measuringtools/types', data) }, update(id, data) { return api.put(`/measuringtools/types/${id}`, data) }, remove(id) { return api.delete(`/measuringtools/types/${id}`) } } } // 3D printed parts (printedparts plugin) export const printedpartsApi = { list(params = {}) { return api.get('/printedparts/items', { params }) }, // Every printed item, paged past the backend's 100-row cap. The label sheet // must print the whole selection, not the first page of it. listAll(params = {}) { return fetchAllPages('/printedparts/items', params) }, get(printeditemid) { return api.get(`/printedparts/items/${printeditemid}`) }, create(data) { return api.post('/printedparts/items', data) }, update(printeditemid, data) { return api.put(`/printedparts/items/${printeditemid}`, data) }, remove(printeditemid) { return api.delete(`/printedparts/items/${printeditemid}`) }, restore(printeditemid) { return api.post(`/printedparts/items/${printeditemid}/restore`) }, uploadImage(printeditemid, file) { const formData = new FormData() formData.append('file', file) return api.post(`/printedparts/items/${printeditemid}/image`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }) }, deleteImage(printeditemid) { return api.delete(`/printedparts/items/${printeditemid}/image`) }, restock(printeditemid, data) { return api.post(`/printedparts/items/${printeditemid}/restock`, data) }, adjust(printeditemid, data) { return api.post(`/printedparts/items/${printeditemid}/adjust`, data) }, kioskItem(itemcode) { return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`) }, kioskTake(data) { return api.post('/printedparts/kiosk/take', data) }, listFiles(printeditemid) { return api.get(`/printedparts/items/${printeditemid}/files`) }, uploadFile(printeditemid, file, note) { const formData = new FormData() formData.append('file', file) if (note) formData.append('note', note) return api.post(`/printedparts/items/${printeditemid}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }) }, removeFile(fileid) { return api.delete(`/printedparts/files/${fileid}`) } }