maintenance/frontend/src/api.ts
jtricerolph ca006f9aea Add edit and delete to task modal
Edit button (update cap): inline form for title, description, priority, unusable flag, due date. Delete button (admin only): hard delete with confirm step, cascades photos/events and cleans files from disk. Backend adds DELETE /api/tasks/:id route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 10:17:57 +00:00

219 lines
8.6 KiB
TypeScript

import type {
Task, TaskDetail, TaskStatus, Location, Category, Asset, AssetDetail,
Contractor, ContractorDetail, ContractorDoc, Template, AppConfig, AuthUser, TaskPhoto,
} from './types'
const BASE = '/maintenance/api'
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (res.status === 401) {
;(window.top ?? window).location.href = '/login'
throw new Error('Unauthenticated')
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
return res.json()
}
// Locations
export function fetchLocations(): Promise<{ categories: Category[]; locations: Location[] }> {
return request('/locations')
}
export function createLocation(body: { name: string; category_id: number }): Promise<Location> {
return request('/locations', { method: 'POST', body: JSON.stringify(body) })
}
export function updateLocation(id: number, body: Partial<Location>): Promise<Location> {
return request(`/locations/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function syncNewbookRooms(): Promise<{ ok: boolean; created: number; updated: number; total: number }> {
return request('/locations/sync-newbook', { method: 'POST', body: JSON.stringify({}) })
}
export function createCategory(body: { name: string; sort_order?: number; is_rooms?: boolean }): Promise<Category> {
return request('/categories', { method: 'POST', body: JSON.stringify(body) })
}
export function updateCategory(id: number, body: Partial<Category>): Promise<Category> {
return request(`/categories/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteCategory(id: number): Promise<{ ok: boolean }> {
return request(`/categories/${id}`, { method: 'DELETE' })
}
// Tasks
export interface TaskFilters {
status?: string
category_id?: number
location_id?: number
asset_id?: number
priority?: string
assigned_to?: string
contractor_id?: number
q?: string
unoccupied?: boolean
}
export function fetchTasks(filters: TaskFilters = {}): Promise<Task[]> {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(filters)) {
if (v !== undefined && v !== null && v !== '' && v !== false) params.set(k, String(v))
}
const qs = params.toString()
return request(`/tasks${qs ? `?${qs}` : ''}`)
}
export function fetchTask(id: number): Promise<TaskDetail> {
return request(`/tasks/${id}`)
}
export function createTask(body: Record<string, unknown>): Promise<Task> {
return request('/tasks', { method: 'POST', body: JSON.stringify(body) })
}
export function updateTask(id: number, body: Record<string, unknown>): Promise<Task> {
return request(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteTask(id: number): Promise<{ ok: boolean }> {
return request(`/tasks/${id}`, { method: 'DELETE' })
}
export function resolveTask(id: number, body: {
status: 'temporary_fix' | 'fixed'
completed_by?: string
completed_by_name?: string
cost?: string
cost_notes?: string
note?: string
}): Promise<Task> {
return request(`/tasks/${id}/resolve`, { method: 'POST', body: JSON.stringify(body) })
}
export function addComment(id: number, note: string, addToTemplate = false): Promise<{ ok: boolean; added_to_template: boolean }> {
return request(`/tasks/${id}/comments`, { method: 'POST', body: JSON.stringify({ note, add_to_template: addToTemplate }) })
}
export function fetchOccupancy(): Promise<{ date: string; occupied_site_ids: string[]; arriving_site_ids: string[] }> {
return request('/occupancy')
}
// Photos — multipart, so no JSON content-type header
export async function uploadTaskPhoto(taskId: number, file: File, stage: string): Promise<TaskPhoto> {
const form = new FormData()
form.append('stage', stage)
form.append('file', file)
const res = await fetch(`${BASE}/tasks/${taskId}/photos`, { method: 'POST', credentials: 'include', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Upload failed: ${res.status}`)
}
return res.json()
}
export function deletePhoto(id: number): Promise<{ ok: boolean }> {
return request(`/photos/${id}`, { method: 'DELETE' })
}
export function photoUrl(filePath: string): string {
return `${BASE}/uploads${filePath}`
}
// History
export interface HistoryFilters {
q?: string
from?: string
to?: string
category_id?: number
location_id?: number
asset_id?: number
include_temporary?: boolean
limit?: number
offset?: number
}
export function fetchHistory(filters: HistoryFilters = {}): Promise<{
tasks: Task[]
totals: { count: number; total_cost: string | null; avg_days_to_fix: string | null }
}> {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(filters)) {
if (v !== undefined && v !== null && v !== '' && v !== false) params.set(k, String(v))
}
const qs = params.toString()
return request(`/history${qs ? `?${qs}` : ''}`)
}
export function historyExportUrl(filters: HistoryFilters = {}): string {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(filters)) {
if (v !== undefined && v !== null && v !== '' && v !== false) params.set(k, String(v))
}
const qs = params.toString()
return `${BASE}/history/export${qs ? `?${qs}` : ''}`
}
// Assets
export function fetchAssets(includeInactive = false): Promise<Asset[]> {
return request(`/assets${includeInactive ? '?include_inactive=true' : ''}`)
}
export function fetchAsset(id: number): Promise<AssetDetail> {
return request(`/assets/${id}`)
}
export function createAsset(body: Record<string, unknown>): Promise<Asset> {
return request('/assets', { method: 'POST', body: JSON.stringify(body) })
}
export function updateAsset(id: number, body: Record<string, unknown>): Promise<Asset> {
return request(`/assets/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
// Contractors
export function fetchContractors(includeInactive = false): Promise<Contractor[]> {
return request(`/contractors${includeInactive ? '?include_inactive=true' : ''}`)
}
export function fetchContractor(id: number): Promise<ContractorDetail> {
return request(`/contractors/${id}`)
}
export function createContractor(body: Record<string, unknown>): Promise<Contractor> {
return request('/contractors', { method: 'POST', body: JSON.stringify(body) })
}
export function updateContractor(id: number, body: Record<string, unknown>): Promise<Contractor> {
return request(`/contractors/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export async function uploadContractorDoc(contractorId: number, file: File, docType: string, expiryDate: string): Promise<ContractorDoc> {
const form = new FormData()
form.append('doc_type', docType)
form.append('expiry_date', expiryDate)
form.append('file', file)
const res = await fetch(`${BASE}/contractors/${contractorId}/docs`, { method: 'POST', credentials: 'include', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Upload failed: ${res.status}`)
}
return res.json()
}
export function deleteContractorDoc(id: number): Promise<{ ok: boolean }> {
return request(`/contractor-docs/${id}`, { method: 'DELETE' })
}
// Templates (recurring tasks)
export function fetchTemplates(): Promise<Template[]> {
return request('/templates')
}
export function createTemplate(body: Record<string, unknown>): Promise<Template> {
return request('/templates', { method: 'POST', body: JSON.stringify(body) })
}
export function updateTemplate(id: number, body: Record<string, unknown>): Promise<Template> {
return request(`/templates/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function runDueTemplates(): Promise<{ ok: boolean; spawned: number }> {
return request('/templates/run-due', { method: 'POST', body: JSON.stringify({}) })
}
// Config
export function fetchConfig(): Promise<AppConfig> {
return request('/config')
}
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
return request(`/config/${key}`, { method: 'PUT', body: JSON.stringify({ value }) })
}
// Assignable staff users — served by the central auth service through the nginx auth proxy
export async function fetchAssignableUsers(): Promise<AuthUser[]> {
const res = await fetch('/maintenance/api/auth/users?app=maintenance', { credentials: 'include' })
if (!res.ok) throw new Error(`Failed to load users: ${res.status}`)
return res.json()
}