Maintenance log book app — initial scaffold
Multi-department fault log: NewBook-synced room locations + manual locations with categories, six-state task flow (submitted/in progress/ hold-parts/hold-later/temporary fix/fixed), photos per stage, priorities with unusable flag and per-task NewBook out-of-order push, costs on resolve, comment/audit thread, recurring task templates with note-to-template carryover, asset register, contractor register with document attachments, staff/contractor allocation, occupancy-aware summary filter, searchable history with CSV export, email notifications. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
6ca395097e
47 changed files with 6727 additions and 0 deletions
218
frontend/src/api.ts
Normal file
218
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
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.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' })
|
||||
}
|
||||
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 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 blockRoomInNewbook(id: number): Promise<{ ok: boolean }> {
|
||||
return request(`/tasks/${id}/newbook-block`, { method: 'POST' })
|
||||
}
|
||||
export function unblockRoomInNewbook(id: number): Promise<{ ok: boolean }> {
|
||||
return request(`/tasks/${id}/newbook-unblock`, { method: 'POST' })
|
||||
}
|
||||
export function fetchOccupancy(): Promise<{ date: string; occupied_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' })
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue