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>
219 lines
8.6 KiB
TypeScript
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()
|
|
}
|