The assignment belongs to the MACHINE, and until now there was no way to set it except the generic relationships card or the API - the form for the thing the feature is about did not exist. MachineForm now carries the picker, and PCForm uses the SAME component rather than its own copy: the PC's set overrides the machine's, and two implementations of that would drift, with the two ends of an override disagreeing being exactly the bug nobody would spot. The shared picker also fixes what PCForm did on save. It wrote row at a time through the generic relationship endpoints, which is a non-atomic reconcile: an HTTP failure part way left a PC half-assigned with nothing recording what was meant. It now calls the reconcile endpoint, which validates the default before writing anything. A relationship type can now say it allows one active row per asset (relationshiptypes.issingular, migration 7d34), and defaultprinter says it. Cardinality belongs to the type rather than the printers plugin: core's create path is where every hand-made link passes, and the next type meaning "exactly one" gets the rule for free. Setting a second default REPLACES the first instead of refusing, because "make this the default" means that - and a card answering 409 would leave the user hunting for the old row. Without it the schema was happy to hold two defaults: the unique constraint is (source, target, type), so two different targets are two valid rows, and the resolver takes the OLDEST - the new default silently lost. Proven by disabling the new rule and watching the tests fail. FOUND WHILE TESTING IN A BROWSER, and it was not mine: MachineForm read .data.data off computersApi.listAll(), which resolves to the ARRAY - fetchAllPages has already unwrapped every page. The whole parallel load threw into the catch, so every dropdown on the machine edit form came up empty and the machine's own values never loaded. A build cannot see this; only opening the page can. GET /api/printers/assignments/for-asset/<id> returns an asset's OWN assignment, without inheritance, because the editor must show what this asset's rows say - otherwise a machine's printers appear ticked on the PC that inherits them and unticking one silently creates an override.
1375 lines
37 KiB
JavaScript
1375 lines
37 KiB
JavaScript
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
|
|
api.interceptors.request.use(config => {
|
|
const token = localStorage.getItem('token')
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
}
|
|
// Send both perpage and per_page for backend compatibility
|
|
if (config.params?.perpage) {
|
|
config.params.per_page = config.params.perpage
|
|
}
|
|
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}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 })
|
|
},
|
|
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}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 })
|
|
},
|
|
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 })
|
|
},
|
|
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)
|
|
},
|
|
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')
|
|
}
|
|
}
|
|
|
|
// 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 })
|
|
},
|
|
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: <big number> }).
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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' } })
|
|
}
|
|
}
|
|
|
|
// 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 })
|
|
}
|
|
}
|
|
|
|
// 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}`)
|
|
}
|
|
}
|