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
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG VITE_HOTEL_NAME
|
||||
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html/maintenance
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="theme-color" content="#b45309" />
|
||||
<title>Maintenance</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
frontend/nginx.conf
Normal file
40
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
client_max_body_size 12m;
|
||||
|
||||
location /maintenance/api/auth/ {
|
||||
proxy_pass http://10.10.10.101:3001/api/auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /maintenance/api/ {
|
||||
proxy_pass http://backend:3001/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location /maintenance/health {
|
||||
proxy_pass http://backend:3001/health;
|
||||
}
|
||||
|
||||
location ~* /maintenance/.*\.(js|css|png|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /maintenance/ {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
try_files $uri $uri/ /maintenance/index.html;
|
||||
}
|
||||
|
||||
location = / {
|
||||
return 301 /maintenance/;
|
||||
}
|
||||
}
|
||||
1901
frontend/package-lock.json
generated
Normal file
1901
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "hnf-maintenance-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
32
frontend/src/App.tsx
Normal file
32
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import AuthGate from './components/AuthGate'
|
||||
import Layout from './components/Layout'
|
||||
import Summary from './pages/Summary'
|
||||
import HistoryPage from './pages/History'
|
||||
import Assets from './pages/Assets'
|
||||
import Contractors from './pages/Contractors'
|
||||
import Recurring from './pages/Recurring'
|
||||
import Locations from './pages/Locations'
|
||||
import Settings from './pages/Settings'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter basename="/maintenance">
|
||||
<AuthGate>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/summary" replace />} />
|
||||
<Route path="/summary" element={<Summary />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/assets" element={<Assets />} />
|
||||
<Route path="/contractors" element={<Contractors />} />
|
||||
<Route path="/recurring" element={<Recurring />} />
|
||||
<Route path="/locations" element={<Locations />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/summary" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
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()
|
||||
}
|
||||
75
frontend/src/components/AssigneeSelect.tsx
Normal file
75
frontend/src/components/AssigneeSelect.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import type { AssignedType, AuthUser, Contractor } from '../types'
|
||||
import { fetchAssignableUsers, fetchContractors } from '../api'
|
||||
|
||||
export interface Assignment {
|
||||
assigned_type: AssignedType
|
||||
assigned_to: string | null
|
||||
assigned_to_name: string | null
|
||||
contractor_id: number | null
|
||||
}
|
||||
|
||||
// Staff / Contractor selector. Staff mode lists users with access to this app
|
||||
// (central auth); contractor mode lists the contractor register.
|
||||
export default function AssigneeSelect({ value, onChange }: {
|
||||
value: Assignment
|
||||
onChange: (a: Assignment) => void
|
||||
}) {
|
||||
const [users, setUsers] = useState<AuthUser[]>([])
|
||||
const [usersError, setUsersError] = useState<string | null>(null)
|
||||
const [contractors, setContractors] = useState<Contractor[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssignableUsers().then(setUsers).catch(err => setUsersError(err.message))
|
||||
fetchContractors().then(setContractors).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const setType = (t: AssignedType) => {
|
||||
onChange({ assigned_type: t, assigned_to: null, assigned_to_name: null, contractor_id: null })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
<label>Allocated to</label>
|
||||
<div className="chip-bar" style={{ marginBottom: 6 }}>
|
||||
<button type="button" className={`chip ${value.assigned_type === 'staff' ? 'active' : ''}`} onClick={() => setType('staff')}>
|
||||
Staff
|
||||
</button>
|
||||
<button type="button" className={`chip ${value.assigned_type === 'contractor' ? 'active' : ''}`} onClick={() => setType('contractor')}>
|
||||
Contractor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{value.assigned_type === 'staff' ? (
|
||||
<>
|
||||
<select
|
||||
value={value.assigned_to ?? ''}
|
||||
onChange={e => {
|
||||
const u = users.find(x => x.email === e.target.value)
|
||||
onChange({ ...value, assigned_to: u?.email ?? null, assigned_to_name: u?.name ?? null, contractor_id: null })
|
||||
}}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{users.map(u => <option key={u.email} value={u.email}>{u.name}</option>)}
|
||||
</select>
|
||||
{usersError && <div className="field-hint">Could not load staff list: {usersError}</div>}
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
value={value.contractor_id ?? ''}
|
||||
onChange={e => onChange({
|
||||
...value,
|
||||
contractor_id: e.target.value ? parseInt(e.target.value) : null,
|
||||
assigned_to: null,
|
||||
assigned_to_name: null,
|
||||
})}
|
||||
>
|
||||
<option value="">Select contractor…</option>
|
||||
{contractors.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}{c.company ? ` — ${c.company}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/AuthGate.tsx
Normal file
51
frontend/src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useEffect, useState, createContext, useContext } from 'react'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthCtx { user: User }
|
||||
const Ctx = createContext<AuthCtx | null>(null)
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/verify?app=maintenance', { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (r.status === 401 || r.status === 403) {
|
||||
window.location.href = `/portal?redirect=${encodeURIComponent(window.location.href)}`
|
||||
return null
|
||||
}
|
||||
if (!r.ok) throw new Error(`Auth check failed: ${r.status}`)
|
||||
return r.json()
|
||||
})
|
||||
.then(data => { if (data) setUser(data) })
|
||||
.catch(err => setError(err.message))
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 32, color: 'var(--danger)', fontFamily: 'var(--font)' }}>
|
||||
Authentication error: {error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
|
||||
}}>
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
|
||||
}
|
||||
57
frontend/src/components/Layout.tsx
Normal file
57
frontend/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { Wrench, ClipboardList, History, Boxes, HardHat, Repeat, MapPin, Settings } from 'lucide-react'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { can } from '../types'
|
||||
|
||||
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
|
||||
|
||||
const NAV = [
|
||||
{ to: '/summary', label: 'Summary', icon: ClipboardList, cap: 'view' },
|
||||
{ to: '/history', label: 'History', icon: History, cap: 'view' },
|
||||
{ to: '/assets', label: 'Assets', icon: Boxes, cap: 'view' },
|
||||
{ to: '/contractors', label: 'Contractors', icon: HardHat, cap: 'view' },
|
||||
{ to: '/recurring', label: 'Recurring', icon: Repeat, cap: 'view' },
|
||||
{ to: '/locations', label: 'Locations', icon: MapPin, cap: 'manage_locations' },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useAuth()
|
||||
const items = NAV.filter(n => can(user, n.cap))
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-logo">
|
||||
<Wrench size={18} strokeWidth={1.75} />
|
||||
Maintenance
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
<Icon {...ICON_PROPS} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-user">{user.name}</div>
|
||||
</aside>
|
||||
|
||||
<header className="top-bar">
|
||||
<Wrench size={18} strokeWidth={1.75} color="var(--gold)" />
|
||||
<span className="top-bar-title">Maintenance</span>
|
||||
<nav className="top-bar-nav">
|
||||
{items.map(({ to, label }) => (
|
||||
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="page-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
204
frontend/src/components/NewTaskModal.tsx
Normal file
204
frontend/src/components/NewTaskModal.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { X, Camera } from 'lucide-react'
|
||||
import type { Category, Location, Task, Priority, AppConfig, Asset } from '../types'
|
||||
import { PRIORITIES, PRIORITY_LABELS } from '../types'
|
||||
import { createTask, fetchTasks, uploadTaskPhoto, blockRoomInNewbook, fetchAssets } from '../api'
|
||||
import AssigneeSelect, { type Assignment } from './AssigneeSelect'
|
||||
import { PriorityBadge, StatusBadge } from './shared'
|
||||
|
||||
export default function NewTaskModal({ categories, locations, config, onClose, onCreated }: {
|
||||
categories: Category[]
|
||||
locations: Location[]
|
||||
config: AppConfig | null
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [locationId, setLocationId] = useState<number | ''>('')
|
||||
const [assetId, setAssetId] = useState<number | ''>('')
|
||||
const [priority, setPriority] = useState<Priority>('medium')
|
||||
const [unusable, setUnusable] = useState(false)
|
||||
const [dueDate, setDueDate] = useState('')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [assets, setAssets] = useState<Asset[]>([])
|
||||
const [existing, setExisting] = useState<Task[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [assignment, setAssignment] = useState<Assignment>({
|
||||
assigned_type: (config?.default_assigned_type as Assignment['assigned_type']) || 'staff',
|
||||
assigned_to: config?.default_assignee || null,
|
||||
assigned_to_name: config?.default_assignee_name || config?.default_assignee || null,
|
||||
contractor_id: config?.default_contractor_id ?? null,
|
||||
})
|
||||
|
||||
const location = useMemo(() => locations.find(l => l.id === locationId), [locations, locationId])
|
||||
const locationAssets = useMemo(
|
||||
() => assets.filter(a => a.location_id === locationId),
|
||||
[assets, locationId]
|
||||
)
|
||||
|
||||
useEffect(() => { fetchAssets().then(setAssets).catch(() => {}) }, [])
|
||||
|
||||
// Duplicate hint: existing open tasks at the chosen location
|
||||
useEffect(() => {
|
||||
if (!locationId) { setExisting([]); return }
|
||||
fetchTasks({ location_id: locationId as number }).then(setExisting).catch(() => setExisting([]))
|
||||
}, [locationId])
|
||||
|
||||
const grouped = useMemo(() => categories.map(c => ({
|
||||
category: c,
|
||||
locations: locations.filter(l => l.category_id === c.id && l.active),
|
||||
})).filter(g => g.locations.length), [categories, locations])
|
||||
|
||||
async function submit() {
|
||||
if (!title.trim() || !locationId) { setError('Title and location are required'); return }
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const task = await createTask({
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
location_id: locationId,
|
||||
asset_id: assetId || null,
|
||||
priority,
|
||||
unusable,
|
||||
due_date: dueDate || null,
|
||||
assigned_type: assignment.assigned_type,
|
||||
assigned_to: assignment.assigned_to,
|
||||
assigned_to_name: assignment.assigned_to_name,
|
||||
contractor_id: assignment.contractor_id,
|
||||
})
|
||||
|
||||
for (const file of files) {
|
||||
await uploadTaskPhoto(task.id, file, 'report').catch(() => {})
|
||||
}
|
||||
|
||||
// Explicit confirm — never block a room in NewBook silently
|
||||
if (unusable && location?.source === 'newbook') {
|
||||
const ok = window.confirm(
|
||||
`Also mark ${location.name} as out of order in NewBook (status: ${config?.newbook_block_status || 'Maintenance'}) so it can't be sold?`
|
||||
)
|
||||
if (ok) await blockRoomInNewbook(task.id).catch(err => window.alert(`NewBook block failed: ${err.message}`))
|
||||
}
|
||||
|
||||
onCreated()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create task')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Report a fault</h2>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="field">
|
||||
<label>Title</label>
|
||||
<input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Shower dripping" autoFocus />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Location</label>
|
||||
<select value={locationId} onChange={e => { setLocationId(e.target.value ? parseInt(e.target.value) : ''); setAssetId('') }}>
|
||||
<option value="">Select location…</option>
|
||||
{grouped.map(g => (
|
||||
<optgroup key={g.category.id} label={g.category.name}>
|
||||
{g.locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{existing.length > 0 && (
|
||||
<div className="card" style={{ background: 'var(--warn-bg)' }}>
|
||||
<strong style={{ fontSize: 12.5 }}>Already open at this location:</strong>
|
||||
{existing.slice(0, 4).map(t => (
|
||||
<div key={t.id} style={{ fontSize: 12.5, marginTop: 4, display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<StatusBadge status={t.status} /> {t.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{locationAssets.length > 0 && (
|
||||
<div className="field">
|
||||
<label>Asset (optional)</label>
|
||||
<select value={assetId} onChange={e => setAssetId(e.target.value ? parseInt(e.target.value) : '')}>
|
||||
<option value="">None</option>
|
||||
{locationAssets.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Description (optional)</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="More detail about the fault…" />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Priority</label>
|
||||
<div className="chip-bar" style={{ marginBottom: 0 }}>
|
||||
{PRIORITIES.map(p => (
|
||||
<button key={p} type="button" className={`chip ${priority === p ? 'active' : ''}`} onClick={() => setPriority(p)}>
|
||||
{PRIORITY_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
<PriorityBadge priority={priority} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="field-check" style={{ marginBottom: 12 }}>
|
||||
<input type="checkbox" checked={unusable} onChange={e => setUnusable(e.target.checked)} />
|
||||
Makes this location unusable / unsellable
|
||||
</label>
|
||||
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Due date (optional)</label>
|
||||
<input type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssigneeSelect value={assignment} onChange={setAssignment} />
|
||||
|
||||
<div className="field">
|
||||
<label>Photos (optional)</label>
|
||||
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
|
||||
<Camera size={14} strokeWidth={1.75} />
|
||||
Add photos
|
||||
<input
|
||||
type="file" accept="image/*" multiple capture="environment" style={{ display: 'none' }}
|
||||
onChange={e => setFiles([...files, ...Array.from(e.target.files || [])])}
|
||||
/>
|
||||
</label>
|
||||
{files.length > 0 && (
|
||||
<div className="field-hint">
|
||||
{files.map((f, i) => (
|
||||
<span key={i} style={{ marginRight: 8 }}>
|
||||
{f.name} <button className="btn btn-sm" style={{ padding: '0 4px' }} onClick={() => setFiles(files.filter((_, j) => j !== i))}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
379
frontend/src/components/TaskModal.tsx
Normal file
379
frontend/src/components/TaskModal.tsx
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
X, Camera, MessageSquare, ArrowRight, RotateCcw, PlusCircle,
|
||||
Ban, CheckCircle2, Image as ImageIcon, PoundSterling, UserRound,
|
||||
} from 'lucide-react'
|
||||
import type { TaskDetail, TaskStatus, TaskEvent, AuthUser } from '../types'
|
||||
import { STATUS_LABELS, TRANSITIONS, can } from '../types'
|
||||
import {
|
||||
fetchTask, updateTask, resolveTask, addComment, uploadTaskPhoto, deletePhoto,
|
||||
blockRoomInNewbook, unblockRoomInNewbook, photoUrl, fetchAssignableUsers,
|
||||
} from '../api'
|
||||
import { useAuth } from './AuthGate'
|
||||
import AssigneeSelect, { type Assignment } from './AssigneeSelect'
|
||||
import { PriorityBadge, StatusBadge, UnusableBadge, formatDate, formatDateTime, ageLabel } from './shared'
|
||||
|
||||
function EventIcon({ type }: { type: string }) {
|
||||
const props = { size: 14, strokeWidth: 1.75 }
|
||||
switch (type) {
|
||||
case 'created': return <PlusCircle {...props} />
|
||||
case 'comment': return <MessageSquare {...props} />
|
||||
case 'photo': return <ImageIcon {...props} />
|
||||
case 'cost': return <PoundSterling {...props} />
|
||||
case 'reassigned': return <UserRound {...props} />
|
||||
case 'reopened': return <RotateCcw {...props} />
|
||||
case 'newbook_block': return <Ban {...props} />
|
||||
case 'newbook_unblock': return <CheckCircle2 {...props} />
|
||||
default: return <ArrowRight {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
function eventLine(e: TaskEvent): string {
|
||||
if (e.event_type === 'status_change' && e.from_status && e.to_status) {
|
||||
return `${STATUS_LABELS[e.from_status]} → ${STATUS_LABELS[e.to_status]}`
|
||||
}
|
||||
if (e.event_type === 'created') return 'Task created'
|
||||
if (e.event_type === 'reopened') return `Reopened (was ${e.from_status ? STATUS_LABELS[e.from_status] : ''})`
|
||||
return ''
|
||||
}
|
||||
|
||||
export default function TaskModal({ taskId, onClose, onChanged }: {
|
||||
taskId: number
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
const [task, setTask] = useState<TaskDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [comment, setComment] = useState('')
|
||||
const [addToTemplate, setAddToTemplate] = useState(false)
|
||||
const [holdUntil, setHoldUntil] = useState('')
|
||||
const [showReassign, setShowReassign] = useState(false)
|
||||
const [assignment, setAssignment] = useState<Assignment | null>(null)
|
||||
|
||||
const [showResolve, setShowResolve] = useState<null | 'temporary_fix' | 'fixed'>(null)
|
||||
const [resolveUsers, setResolveUsers] = useState<AuthUser[]>([])
|
||||
const [completedBy, setCompletedBy] = useState('')
|
||||
const [cost, setCost] = useState('')
|
||||
const [costNotes, setCostNotes] = useState('')
|
||||
const [resolveNote, setResolveNote] = useState('')
|
||||
const [resolveFile, setResolveFile] = useState<File | null>(null)
|
||||
|
||||
const load = () => fetchTask(taskId).then(t => {
|
||||
setTask(t)
|
||||
setAssignment({
|
||||
assigned_type: t.assigned_type,
|
||||
assigned_to: t.assigned_to,
|
||||
assigned_to_name: t.assigned_to_name,
|
||||
contractor_id: t.contractor_id,
|
||||
})
|
||||
}).catch(err => setError(err.message))
|
||||
|
||||
useEffect(() => { load() }, [taskId])
|
||||
useEffect(() => {
|
||||
if (showResolve) {
|
||||
setCompletedBy(user.email)
|
||||
fetchAssignableUsers().then(setResolveUsers).catch(() => setResolveUsers([]))
|
||||
}
|
||||
}, [showResolve])
|
||||
|
||||
async function run(fn: () => Promise<unknown>) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await fn()
|
||||
await load()
|
||||
onChanged()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Action failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
{error ? <div className="error-banner">{error}</div> : 'Loading…'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const canUpdate = can(user, 'update')
|
||||
const canResolve = can(user, 'resolve')
|
||||
const canReport = can(user, 'report')
|
||||
const showCosts = can(user, 'costs')
|
||||
const isRoom = task.location_source === 'newbook' && !!task.newbook_site_id
|
||||
|
||||
// Non-resolve transitions offered as buttons; resolve statuses open the resolve form
|
||||
const moves = (TRANSITIONS[task.status] || []).filter(s => !['temporary_fix', 'fixed'].includes(s))
|
||||
const resolveMoves = (TRANSITIONS[task.status] || []).filter(s => ['temporary_fix', 'fixed'].includes(s)) as Array<'temporary_fix' | 'fixed'>
|
||||
|
||||
async function doTransition(status: TaskStatus) {
|
||||
const body: Record<string, unknown> = { status }
|
||||
if (status === 'hold_scheduled') {
|
||||
if (!holdUntil) { setError('Pick a hold-until date first'); return }
|
||||
body.hold_until = holdUntil
|
||||
}
|
||||
await run(() => updateTask(task!.id, body))
|
||||
}
|
||||
|
||||
async function doResolve() {
|
||||
const status = showResolve!
|
||||
const u = resolveUsers.find(x => x.email === completedBy)
|
||||
await run(async () => {
|
||||
await resolveTask(task!.id, {
|
||||
status,
|
||||
completed_by: completedBy || undefined,
|
||||
completed_by_name: u?.name || undefined,
|
||||
cost: showCosts && cost ? cost : undefined,
|
||||
cost_notes: showCosts && costNotes ? costNotes : undefined,
|
||||
note: resolveNote || undefined,
|
||||
})
|
||||
if (resolveFile) await uploadTaskPhoto(task!.id, resolveFile, 'resolution').catch(() => {})
|
||||
if (task!.newbook_blocked && status === 'fixed') {
|
||||
const ok = window.confirm('This room is blocked in NewBook — release it now?')
|
||||
if (ok) await unblockRoomInNewbook(task!.id).catch(() => {})
|
||||
}
|
||||
})
|
||||
setShowResolve(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2>#{task.id} — {task.title}</h2>
|
||||
<div className="task-card-meta" style={{ marginTop: 6 }}>
|
||||
<StatusBadge status={task.status} />
|
||||
<PriorityBadge priority={task.priority} />
|
||||
{task.unusable && <UnusableBadge />}
|
||||
{task.newbook_blocked && <span className="badge badge-outline">Blocked in NewBook</span>}
|
||||
{task.template_id && <span className="badge badge-outline">Recurring</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="task-card-meta" style={{ marginBottom: 10 }}>
|
||||
<span>{task.location_name} · {task.category_name}</span>
|
||||
{task.asset_name && <span>Asset: {task.asset_name}</span>}
|
||||
<span>Reported by {task.created_by_name || task.created_by} · {formatDateTime(task.created_at)} ({ageLabel(task.created_at)} ago)</span>
|
||||
</div>
|
||||
<div className="task-card-meta" style={{ marginBottom: 10 }}>
|
||||
<span>
|
||||
Allocated: {task.assigned_type === 'contractor'
|
||||
? `${task.contractor_name || '—'}${task.contractor_company ? ` (${task.contractor_company})` : ''} [contractor]`
|
||||
: (task.assigned_to_name || 'Unassigned')}
|
||||
</span>
|
||||
{task.due_date && <span>Due {formatDate(task.due_date)}</span>}
|
||||
{task.hold_until && <span>On hold until {formatDate(task.hold_until)}</span>}
|
||||
{task.completed_at && <span>Completed by {task.completed_by_name} · {formatDateTime(task.completed_at)}</span>}
|
||||
{showCosts && task.cost != null && <span>Cost £{task.cost}{task.cost_notes ? ` (${task.cost_notes})` : ''}</span>}
|
||||
</div>
|
||||
|
||||
{task.description && <p style={{ whiteSpace: 'pre-wrap', margin: '0 0 12px' }}>{task.description}</p>}
|
||||
|
||||
{/* Photos */}
|
||||
<div className="section-title">Photos</div>
|
||||
<div className="photo-grid">
|
||||
{task.photos.map(p => (
|
||||
<div key={p.id} className="photo-thumb-wrap">
|
||||
<a href={photoUrl(p.file_path)} target="_blank" rel="noreferrer">
|
||||
<img className="photo-thumb" src={photoUrl(p.file_path)} alt={p.file_name} title={`${p.stage} — ${p.uploaded_by}`} />
|
||||
</a>
|
||||
{(p.uploaded_by === user.email || canUpdate) && (
|
||||
<button className="photo-del" onClick={() => run(() => deletePhoto(p.id))} title="Delete photo">×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{canReport && (
|
||||
<label className="btn btn-sm" style={{ cursor: 'pointer', alignSelf: 'center' }}>
|
||||
<Camera size={14} strokeWidth={1.75} /> Add
|
||||
<input
|
||||
type="file" accept="image/*" capture="environment" style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) run(() => uploadTaskPhoto(task.id, f, task.status === 'submitted' ? 'report' : 'progress'))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* State actions */}
|
||||
{(canUpdate || canResolve) && task.status !== 'fixed' && !showResolve && (
|
||||
<>
|
||||
<div className="section-title">Actions</div>
|
||||
<div className="chip-bar">
|
||||
{canUpdate && moves.map(s => (
|
||||
<button key={s} className="btn btn-sm" disabled={busy} onClick={() => doTransition(s)}>
|
||||
<ArrowRight size={13} strokeWidth={1.75} /> {STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
{canResolve && resolveMoves.map(s => (
|
||||
<button key={s} className="btn btn-sm btn-primary" disabled={busy} onClick={() => setShowResolve(s)}>
|
||||
<CheckCircle2 size={13} strokeWidth={1.75} /> {STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{canUpdate && (TRANSITIONS[task.status] || []).includes('hold_scheduled') && (
|
||||
<div className="field-row" style={{ maxWidth: 260 }}>
|
||||
<div className="field">
|
||||
<label>Hold until (for “Hold — Later Date”)</label>
|
||||
<input type="date" value={holdUntil} onChange={e => setHoldUntil(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canUpdate && task.status === 'fixed' && (
|
||||
<div className="chip-bar">
|
||||
<button className="btn btn-sm" disabled={busy} onClick={() => doTransition('submitted')}>
|
||||
<RotateCcw size={13} strokeWidth={1.75} /> Reopen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NewBook room block */}
|
||||
{canUpdate && isRoom && task.status !== 'fixed' && (
|
||||
<div className="chip-bar">
|
||||
{task.newbook_blocked ? (
|
||||
<button className="btn btn-sm" disabled={busy} onClick={() => run(() => unblockRoomInNewbook(task.id))}>
|
||||
<CheckCircle2 size={13} strokeWidth={1.75} /> Release room in NewBook
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-sm" disabled={busy} onClick={() => {
|
||||
if (window.confirm(`Mark ${task.location_name} out of order in NewBook so it can't be sold?`)) {
|
||||
run(() => blockRoomInNewbook(task.id))
|
||||
}
|
||||
}}>
|
||||
<Ban size={13} strokeWidth={1.75} /> Block room in NewBook
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolve form */}
|
||||
{showResolve && (
|
||||
<div className="card" style={{ background: 'var(--ok-bg)' }}>
|
||||
<div className="section-title" style={{ marginTop: 0 }}>
|
||||
Mark as {STATUS_LABELS[showResolve]}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Completed by</label>
|
||||
<select value={completedBy} onChange={e => setCompletedBy(e.target.value)}>
|
||||
<option value={user.email}>{user.name} (me)</option>
|
||||
{resolveUsers.filter(u2 => u2.email !== user.email).map(u2 => (
|
||||
<option key={u2.email} value={u2.email}>{u2.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{showCosts && (
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Cost / value (optional)</label>
|
||||
<input type="number" step="0.01" min="0" value={cost} onChange={e => setCost(e.target.value)} placeholder="0.00" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Cost notes</label>
|
||||
<input type="text" value={costNotes} onChange={e => setCostNotes(e.target.value)} placeholder="e.g. new valve + labour" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label>Note (optional)</label>
|
||||
<textarea value={resolveNote} onChange={e => setResolveNote(e.target.value)} placeholder="How was it fixed?" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Photo of the fix? (optional but encouraged)</label>
|
||||
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
|
||||
<Camera size={14} strokeWidth={1.75} /> {resolveFile ? resolveFile.name : 'Add photo'}
|
||||
<input
|
||||
type="file" accept="image/*" capture="environment" style={{ display: 'none' }}
|
||||
onChange={e => setResolveFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setShowResolve(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={doResolve}>
|
||||
{busy ? 'Saving…' : `Confirm ${STATUS_LABELS[showResolve]}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reassign */}
|
||||
{canUpdate && task.status !== 'fixed' && (
|
||||
<>
|
||||
<button className="btn btn-sm" style={{ marginBottom: 8 }} onClick={() => setShowReassign(!showReassign)}>
|
||||
<UserRound size={13} strokeWidth={1.75} /> Reallocate
|
||||
</button>
|
||||
{showReassign && assignment && (
|
||||
<div className="card">
|
||||
<AssigneeSelect value={assignment} onChange={setAssignment} />
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={() => setShowReassign(false)}>Cancel</button>
|
||||
<button className="btn btn-sm btn-primary" disabled={busy} onClick={() => {
|
||||
run(() => updateTask(task.id, { ...assignment }))
|
||||
setShowReassign(false)
|
||||
}}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Thread */}
|
||||
<div className="section-title">Activity</div>
|
||||
<div className="timeline">
|
||||
{task.events.map(e => (
|
||||
<div key={e.id} className="timeline-item">
|
||||
<span className="timeline-icon"><EventIcon type={e.event_type} /></span>
|
||||
<div className="timeline-body">
|
||||
{eventLine(e) && <div><strong>{eventLine(e)}</strong></div>}
|
||||
{e.note && <div className="timeline-note">{e.note}</div>}
|
||||
<div className="timeline-meta">{e.user_name || '—'} · {formatDateTime(e.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canReport && (
|
||||
<div className="field">
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="Add a note / update…"
|
||||
style={{ minHeight: 52 }}
|
||||
/>
|
||||
{task.template_id && (
|
||||
<label className="field-check" style={{ margin: '6px 0' }}>
|
||||
<input type="checkbox" checked={addToTemplate} onChange={e => setAddToTemplate(e.target.checked)} />
|
||||
Also add this note to the recurring template (shows on future occurrences)
|
||||
</label>
|
||||
)}
|
||||
<div className="modal-actions" style={{ marginTop: 6 }}>
|
||||
<button className="btn btn-sm btn-primary" disabled={busy || !comment.trim()} onClick={() => {
|
||||
run(() => addComment(task.id, comment.trim(), addToTemplate))
|
||||
setComment('')
|
||||
setAddToTemplate(false)
|
||||
}}>
|
||||
<MessageSquare size={13} strokeWidth={1.75} /> Add note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
frontend/src/components/shared.tsx
Normal file
44
frontend/src/components/shared.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { AlertTriangle } from 'lucide-react'
|
||||
import type { Priority, TaskStatus } from '../types'
|
||||
import { PRIORITY_LABELS, STATUS_LABELS } from '../types'
|
||||
|
||||
export function PriorityBadge({ priority }: { priority: Priority }) {
|
||||
return <span className={`badge badge-prio-${priority}`}>{PRIORITY_LABELS[priority]}</span>
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: TaskStatus }) {
|
||||
return <span className={`badge badge-st-${status}`}>{STATUS_LABELS[status]}</span>
|
||||
}
|
||||
|
||||
export function UnusableBadge() {
|
||||
return (
|
||||
<span className="badge badge-unusable">
|
||||
<AlertTriangle size={11} strokeWidth={1.75} />
|
||||
Unusable
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + ' ' +
|
||||
d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function daysOpen(createdAt: string): number {
|
||||
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 86400000)
|
||||
}
|
||||
|
||||
export function ageLabel(createdAt: string): string {
|
||||
const days = daysOpen(createdAt)
|
||||
if (days === 0) return 'today'
|
||||
if (days === 1) return '1 day'
|
||||
return `${days} days`
|
||||
}
|
||||
377
frontend/src/index.css
Normal file
377
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
/* Stack design system tokens — include verbatim in every app */
|
||||
:root {
|
||||
--navy: #1a1a2e;
|
||||
--navy-dark: #0f0f20;
|
||||
--gold: #c9a84c;
|
||||
--gold-light: #e8c96d;
|
||||
--surface: rgba(255,255,255,0.07);
|
||||
--surface-2: rgba(255,255,255,0.08);
|
||||
--text: rgba(255,255,255,0.88);
|
||||
--text-muted: rgba(255,255,255,0.48);
|
||||
--body-bg: #f4f5f7;
|
||||
--card-bg: #ffffff;
|
||||
--card-border: #e4e8ee;
|
||||
--text-dark: #1e293b;
|
||||
--text-mid: #64748b;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
||||
--danger: #dc2626;
|
||||
--radius: 10px;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
|
||||
|
||||
/* App theme + semantic tokens */
|
||||
:root {
|
||||
--app-primary: #b45309;
|
||||
--app-primary-light: #d97706;
|
||||
|
||||
--prio-low: #64748b;
|
||||
--prio-medium: #2563eb;
|
||||
--prio-high: #d97706;
|
||||
--prio-urgent: #dc2626;
|
||||
|
||||
--st-submitted: #2563eb;
|
||||
--st-in-progress: #7c3aed;
|
||||
--st-hold: #64748b;
|
||||
--st-temporary: #d97706;
|
||||
--st-fixed: #16a34a;
|
||||
|
||||
--danger-bg: #fef2f2;
|
||||
--warn-bg: #fffbeb;
|
||||
--ok-bg: #f0fdf4;
|
||||
|
||||
--sidebar-w: 240px;
|
||||
--topbar-h: 56px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
||||
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
|
||||
|
||||
/* ── App shell ─────────────────────────────────────────────── */
|
||||
.app-shell { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
background: var(--navy);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar-logo {
|
||||
padding: 20px 16px 12px;
|
||||
color: var(--gold);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.sidebar-logo svg { opacity: .8; }
|
||||
.sidebar-nav { flex: 1; padding: 8px 0; }
|
||||
.sidebar-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
|
||||
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
|
||||
.sidebar-user {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: none;
|
||||
height: var(--topbar-h);
|
||||
background: var(--navy);
|
||||
color: var(--text);
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); }
|
||||
.top-bar-nav { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }
|
||||
.top-bar-nav::-webkit-scrollbar { display: none; }
|
||||
.top-bar-nav a {
|
||||
color: var(--text-muted);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.top-bar-nav a.active { color: var(--gold); }
|
||||
|
||||
.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
.top-bar { display: flex; }
|
||||
.app-shell { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ── Page chrome ───────────────────────────────────────────── */
|
||||
.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; }
|
||||
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.page-header h1 { font-size: 18px; margin: 0; flex: 1; }
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-dark);
|
||||
border-radius: var(--radius);
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
transition: background .12s, border-color .12s;
|
||||
}
|
||||
.btn:hover { border-color: var(--text-mid); }
|
||||
.btn:disabled { opacity: .5; cursor: default; }
|
||||
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
|
||||
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
|
||||
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; }
|
||||
|
||||
/* ── Forms ─────────────────────────────────────────────────── */
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
|
||||
.field input[type="text"], .field input[type="email"], .field input[type="date"],
|
||||
.field input[type="number"], .field select, .field textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13.5px;
|
||||
font-family: var(--font);
|
||||
color: var(--text-dark);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
.field textarea { min-height: 72px; resize: vertical; }
|
||||
.field-row { display: flex; gap: 12px; }
|
||||
.field-row > .field { flex: 1; }
|
||||
.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
|
||||
.field-check input { width: 16px; height: 16px; accent-color: var(--gold); }
|
||||
.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; }
|
||||
|
||||
/* ── Cards & lists ─────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow .12s;
|
||||
}
|
||||
.task-card:hover { box-shadow: var(--shadow-md); }
|
||||
.task-card.urgent { border-left: 4px solid var(--prio-urgent); }
|
||||
.task-card.high { border-left: 4px solid var(--prio-high); }
|
||||
.task-card.unusable { background: var(--danger-bg); }
|
||||
.task-card-main { flex: 1; min-width: 0; }
|
||||
.task-card-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.task-card-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.task-card-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
/* ── Badges ────────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 20px;
|
||||
padding: 2px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-prio-low { background: var(--prio-low); }
|
||||
.badge-prio-medium { background: var(--prio-medium); }
|
||||
.badge-prio-high { background: var(--prio-high); }
|
||||
.badge-prio-urgent { background: var(--prio-urgent); }
|
||||
|
||||
.badge-st-submitted { background: var(--st-submitted); }
|
||||
.badge-st-in_progress { background: var(--st-in-progress); }
|
||||
.badge-st-hold_parts, .badge-st-hold_scheduled { background: var(--st-hold); }
|
||||
.badge-st-temporary_fix { background: var(--st-temporary); }
|
||||
.badge-st-fixed { background: var(--st-fixed); }
|
||||
|
||||
.badge-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--text-mid);
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-unusable { background: var(--danger); }
|
||||
|
||||
/* ── Filter chips ──────────────────────────────────────────── */
|
||||
.chip-bar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-mid);
|
||||
user-select: none;
|
||||
font-family: var(--font);
|
||||
transition: all .12s;
|
||||
}
|
||||
.chip:hover { border-color: var(--gold); color: var(--text-dark); }
|
||||
.chip.active { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
|
||||
|
||||
/* ── Modal ─────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15,15,32,.55);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 24px 12px;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
padding: 20px;
|
||||
margin: auto 0;
|
||||
}
|
||||
.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
|
||||
.modal-header h2 { font-size: 16px; margin: 0; flex: 1; }
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-mid);
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
}
|
||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Timeline / thread ─────────────────────────────────────── */
|
||||
.timeline { margin: 8px 0; }
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.timeline-item:last-child { border-bottom: none; }
|
||||
.timeline-icon { color: var(--text-mid); flex-shrink: 0; margin-top: 1px; }
|
||||
.timeline-body { flex: 1; min-width: 0; }
|
||||
.timeline-note { white-space: pre-wrap; }
|
||||
.timeline-meta { font-size: 11.5px; color: var(--text-mid); margin-top: 2px; }
|
||||
|
||||
/* ── Photos ────────────────────────────────────────────────── */
|
||||
.photo-grid { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
|
||||
.photo-thumb {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--card-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.photo-thumb-wrap { position: relative; }
|
||||
.photo-del {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Tables ────────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
|
||||
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
table.data th {
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: var(--text-mid);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; }
|
||||
table.data tr:last-child td { border-bottom: none; }
|
||||
table.data tr.clickable { cursor: pointer; }
|
||||
table.data tr.clickable:hover td { background: var(--body-bg); }
|
||||
|
||||
/* ── Stats strip ───────────────────────────────────────────── */
|
||||
.stats-strip { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.stat-box {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 10px 16px;
|
||||
min-width: 110px;
|
||||
}
|
||||
.stat-box .stat-value { font-size: 18px; font-weight: 700; }
|
||||
.stat-box .stat-label { font-size: 11px; color: var(--text-mid); text-transform: uppercase; letter-spacing: .04em; }
|
||||
|
||||
/* ── Misc ──────────────────────────────────────────────────── */
|
||||
.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
|
||||
.error-banner {
|
||||
background: var(--danger-bg);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
|
||||
.muted { color: var(--text-mid); }
|
||||
.overdue { color: var(--danger); font-weight: 600; }
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
206
frontend/src/pages/Assets.tsx
Normal file
206
frontend/src/pages/Assets.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import type { Asset, AssetDetail, Location } from '../types'
|
||||
import { can } from '../types'
|
||||
import { fetchAssets, fetchAsset, createAsset, updateAsset, fetchLocations } from '../api'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import TaskModal from '../components/TaskModal'
|
||||
import { PriorityBadge, StatusBadge, formatDate } from '../components/shared'
|
||||
|
||||
interface AssetForm {
|
||||
id?: number
|
||||
name: string
|
||||
location_id: number | ''
|
||||
make_model: string
|
||||
serial_no: string
|
||||
install_date: string
|
||||
notes: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const EMPTY: AssetForm = { name: '', location_id: '', make_model: '', serial_no: '', install_date: '', notes: '', active: true }
|
||||
|
||||
export default function Assets() {
|
||||
const { user } = useAuth()
|
||||
const [assets, setAssets] = useState<Asset[]>([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [detail, setDetail] = useState<AssetDetail | null>(null)
|
||||
const [form, setForm] = useState<AssetForm | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [openTask, setOpenTask] = useState<number | null>(null)
|
||||
|
||||
const canManage = can(user, 'manage_assets')
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchAssets().then(setAssets).catch(err => setError(err.message))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => { fetchLocations().then(d => setLocations(d.locations.filter(l => l.active))).catch(() => {}) }, [])
|
||||
|
||||
async function save() {
|
||||
if (!form || !form.name.trim() || !form.location_id) { setError('Name and location required'); return }
|
||||
try {
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
location_id: form.location_id,
|
||||
make_model: form.make_model || null,
|
||||
serial_no: form.serial_no || null,
|
||||
install_date: form.install_date || null,
|
||||
notes: form.notes || null,
|
||||
active: form.active,
|
||||
}
|
||||
if (form.id) await updateAsset(form.id, body)
|
||||
else await createAsset(body)
|
||||
setForm(null)
|
||||
setError(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Asset register</h1>
|
||||
{canManage && (
|
||||
<button className="btn btn-primary" onClick={() => setForm(EMPTY)}>
|
||||
<Plus size={14} strokeWidth={1.75} /> Add asset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr><th>Asset</th><th>Location</th><th>Make / model</th><th>Open tasks</th><th>Recurring</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assets.map(a => (
|
||||
<tr key={a.id} className="clickable" onClick={() => fetchAsset(a.id).then(setDetail).catch(err => setError(err.message))}>
|
||||
<td>{a.name}</td>
|
||||
<td>{a.location_name}</td>
|
||||
<td>{a.make_model || '—'}</td>
|
||||
<td>{a.open_tasks || 0}</td>
|
||||
<td>{a.recurring_count || 0}</td>
|
||||
</tr>
|
||||
))}
|
||||
{assets.length === 0 && <tr><td colSpan={5} className="empty-state">No assets yet — add the boiler, lifts, fridges…</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Asset detail modal */}
|
||||
{detail && (
|
||||
<div className="modal-overlay" onClick={() => setDetail(null)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{detail.name}</h2>
|
||||
<button className="modal-close" onClick={() => setDetail(null)}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
<div className="task-card-meta" style={{ marginBottom: 10 }}>
|
||||
<span>{detail.location_name}</span>
|
||||
{detail.make_model && <span>{detail.make_model}</span>}
|
||||
{detail.serial_no && <span>SN {detail.serial_no}</span>}
|
||||
{detail.install_date && <span>Installed {formatDate(detail.install_date)}</span>}
|
||||
</div>
|
||||
{detail.notes && <p style={{ whiteSpace: 'pre-wrap' }}>{detail.notes}</p>}
|
||||
|
||||
{detail.templates.length > 0 && (
|
||||
<>
|
||||
<div className="section-title">Recurring service tasks</div>
|
||||
{detail.templates.map(tp => (
|
||||
<div key={tp.id} className="task-card-meta" style={{ marginBottom: 4 }}>
|
||||
<span>{tp.title}</span>
|
||||
<span>every {tp.interval_value} {tp.interval_unit}</span>
|
||||
<span>next due {formatDate(tp.next_due)}</span>
|
||||
{!tp.active && <span className="badge badge-outline">paused</span>}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="section-title">Task history</div>
|
||||
{detail.tasks.length === 0 && <div className="muted">No tasks logged against this asset.</div>}
|
||||
{detail.tasks.map(t => (
|
||||
<div key={t.id} className="timeline-item clickable" style={{ cursor: 'pointer' }} onClick={() => setOpenTask(t.id)}>
|
||||
<div className="timeline-body">
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<StatusBadge status={t.status} /> <PriorityBadge priority={t.priority} /> {t.title}
|
||||
</div>
|
||||
<div className="timeline-meta">
|
||||
{formatDate(t.created_at)}{t.completed_at ? ` → ${formatDate(t.completed_at)} by ${t.completed_by_name}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canManage && (
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => {
|
||||
setForm({
|
||||
id: detail.id, name: detail.name, location_id: detail.location_id,
|
||||
make_model: detail.make_model || '', serial_no: detail.serial_no || '',
|
||||
install_date: detail.install_date?.slice(0, 10) || '', notes: detail.notes || '',
|
||||
active: detail.active,
|
||||
})
|
||||
setDetail(null)
|
||||
}}>Edit</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset create/edit modal */}
|
||||
{form && (
|
||||
<div className="modal-overlay" onClick={() => setForm(null)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{form.id ? 'Edit asset' : 'Add asset'}</h2>
|
||||
<button className="modal-close" onClick={() => setForm(null)}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
<div className="field"><label>Name</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="e.g. Kitchen walk-in fridge" autoFocus />
|
||||
</div>
|
||||
<div className="field"><label>Location</label>
|
||||
<select value={form.location_id} onChange={e => setForm({ ...form, location_id: e.target.value ? parseInt(e.target.value) : '' })}>
|
||||
<option value="">Select location…</option>
|
||||
{locations.map(l => <option key={l.id} value={l.id}>{l.name} ({l.category_name})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Make / model</label>
|
||||
<input type="text" value={form.make_model} onChange={e => setForm({ ...form, make_model: e.target.value })} />
|
||||
</div>
|
||||
<div className="field"><label>Serial no</label>
|
||||
<input type="text" value={form.serial_no} onChange={e => setForm({ ...form, serial_no: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field"><label>Install date</label>
|
||||
<input type="date" value={form.install_date} onChange={e => setForm({ ...form, install_date: e.target.value })} />
|
||||
</div>
|
||||
<div className="field"><label>Notes</label>
|
||||
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
|
||||
</div>
|
||||
{form.id && (
|
||||
<label className="field-check">
|
||||
<input type="checkbox" checked={form.active} onChange={e => setForm({ ...form, active: e.target.checked })} />
|
||||
Active
|
||||
</label>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={save}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openTask !== null && <TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={load} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
238
frontend/src/pages/Contractors.tsx
Normal file
238
frontend/src/pages/Contractors.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Plus, X, FileText, Trash2, Upload } from 'lucide-react'
|
||||
import type { Contractor, ContractorDetail } from '../types'
|
||||
import { can } from '../types'
|
||||
import {
|
||||
fetchContractors, fetchContractor, createContractor, updateContractor,
|
||||
uploadContractorDoc, deleteContractorDoc, photoUrl,
|
||||
} from '../api'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import TaskModal from '../components/TaskModal'
|
||||
import { PriorityBadge, StatusBadge, formatDate } from '../components/shared'
|
||||
|
||||
interface ContractorForm {
|
||||
id?: number
|
||||
name: string
|
||||
company: string
|
||||
phone: string
|
||||
email: string
|
||||
address: string
|
||||
notes: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const EMPTY: ContractorForm = { name: '', company: '', phone: '', email: '', address: '', notes: '', active: true }
|
||||
|
||||
export default function Contractors() {
|
||||
const { user } = useAuth()
|
||||
const [contractors, setContractors] = useState<Contractor[]>([])
|
||||
const [detail, setDetail] = useState<ContractorDetail | null>(null)
|
||||
const [form, setForm] = useState<ContractorForm | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [openTask, setOpenTask] = useState<number | null>(null)
|
||||
const [docType, setDocType] = useState('')
|
||||
const [docExpiry, setDocExpiry] = useState('')
|
||||
|
||||
const canManage = can(user, 'manage_contractors')
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchContractors().then(setContractors).catch(err => setError(err.message))
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const reloadDetail = (id: number) => fetchContractor(id).then(setDetail).catch(err => setError(err.message))
|
||||
|
||||
async function save() {
|
||||
if (!form || !form.name.trim()) { setError('Name required'); return }
|
||||
try {
|
||||
const body = {
|
||||
name: form.name.trim(), company: form.company || null, phone: form.phone || null,
|
||||
email: form.email || null, address: form.address || null, notes: form.notes || null, active: form.active,
|
||||
}
|
||||
if (form.id) await updateContractor(form.id, body)
|
||||
else await createContractor(body)
|
||||
setForm(null)
|
||||
setError(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
}
|
||||
}
|
||||
|
||||
const expiringSoon = (d: string | null | undefined) =>
|
||||
d && new Date(d).getTime() < Date.now() + 30 * 86400000
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Contractors</h1>
|
||||
{canManage && (
|
||||
<button className="btn btn-primary" onClick={() => setForm(EMPTY)}>
|
||||
<Plus size={14} strokeWidth={1.75} /> Add contractor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Company</th><th>Phone</th><th>Open tasks</th><th>Docs</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{contractors.map(c => (
|
||||
<tr key={c.id} className="clickable" onClick={() => reloadDetail(c.id)}>
|
||||
<td>{c.name}</td>
|
||||
<td>{c.company || '—'}</td>
|
||||
<td>{c.phone || '—'}</td>
|
||||
<td>{c.open_tasks || 0}</td>
|
||||
<td>
|
||||
{c.doc_count || 0}
|
||||
{expiringSoon(c.earliest_doc_expiry) && <span className="overdue"> · doc expiring</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{contractors.length === 0 && <tr><td colSpan={5} className="empty-state">No contractors yet.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Detail modal */}
|
||||
{detail && (
|
||||
<div className="modal-overlay" onClick={() => setDetail(null)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{detail.name}{detail.company ? ` — ${detail.company}` : ''}</h2>
|
||||
<button className="modal-close" onClick={() => setDetail(null)}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
<div className="task-card-meta" style={{ marginBottom: 10 }}>
|
||||
{detail.phone && <span>{detail.phone}</span>}
|
||||
{detail.email && <span>{detail.email}</span>}
|
||||
{detail.address && <span>{detail.address}</span>}
|
||||
</div>
|
||||
{detail.notes && <p style={{ whiteSpace: 'pre-wrap' }}>{detail.notes}</p>}
|
||||
|
||||
<div className="section-title">Documents</div>
|
||||
{detail.docs.length === 0 && <div className="muted">No documents uploaded.</div>}
|
||||
{detail.docs.map(d => (
|
||||
<div key={d.id} className="timeline-item">
|
||||
<span className="timeline-icon"><FileText size={14} strokeWidth={1.75} /></span>
|
||||
<div className="timeline-body">
|
||||
<a href={photoUrl(d.file_path)} target="_blank" rel="noreferrer">{d.doc_type || d.file_name}</a>
|
||||
<div className="timeline-meta">
|
||||
{d.expiry_date
|
||||
? <span className={expiringSoon(d.expiry_date) ? 'overdue' : ''}>expires {formatDate(d.expiry_date)}</span>
|
||||
: 'no expiry'}
|
||||
{' · '}uploaded {formatDate(d.uploaded_at)}
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button className="btn btn-sm" onClick={() => deleteContractorDoc(d.id).then(() => reloadDetail(detail.id))}>
|
||||
<Trash2 size={13} strokeWidth={1.75} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canManage && (
|
||||
<div className="card" style={{ marginTop: 8 }}>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Document type</label>
|
||||
<input type="text" value={docType} onChange={e => setDocType(e.target.value)} placeholder="e.g. Liability insurance" />
|
||||
</div>
|
||||
<div className="field"><label>Expiry (optional)</label>
|
||||
<input type="date" value={docExpiry} onChange={e => setDocExpiry(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
|
||||
<Upload size={13} strokeWidth={1.75} /> Upload document
|
||||
<input
|
||||
type="file" accept="image/*,application/pdf" style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) uploadContractorDoc(detail.id, f, docType, docExpiry)
|
||||
.then(() => { setDocType(''); setDocExpiry(''); reloadDetail(detail.id); load() })
|
||||
.catch(err => setError(err.message))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-title">Recent tasks</div>
|
||||
{detail.tasks.length === 0 && <div className="muted">No tasks allocated yet.</div>}
|
||||
{detail.tasks.map(t => (
|
||||
<div key={t.id} className="timeline-item" style={{ cursor: 'pointer' }} onClick={() => setOpenTask(t.id)}>
|
||||
<div className="timeline-body">
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<StatusBadge status={t.status} /> <PriorityBadge priority={t.priority} /> {t.title}
|
||||
</div>
|
||||
<div className="timeline-meta">{t.location_name} · {formatDate(t.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canManage && (
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => {
|
||||
setForm({
|
||||
id: detail.id, name: detail.name, company: detail.company || '', phone: detail.phone || '',
|
||||
email: detail.email || '', address: detail.address || '', notes: detail.notes || '', active: detail.active,
|
||||
})
|
||||
setDetail(null)
|
||||
}}>Edit</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/edit modal */}
|
||||
{form && (
|
||||
<div className="modal-overlay" onClick={() => setForm(null)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{form.id ? 'Edit contractor' : 'Add contractor'}</h2>
|
||||
<button className="modal-close" onClick={() => setForm(null)}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Contact name</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} autoFocus />
|
||||
</div>
|
||||
<div className="field"><label>Company</label>
|
||||
<input type="text" value={form.company} onChange={e => setForm({ ...form, company: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Phone</label>
|
||||
<input type="text" value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} />
|
||||
</div>
|
||||
<div className="field"><label>Email</label>
|
||||
<input type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field"><label>Address</label>
|
||||
<input type="text" value={form.address} onChange={e => setForm({ ...form, address: e.target.value })} />
|
||||
</div>
|
||||
<div className="field"><label>Notes</label>
|
||||
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
|
||||
</div>
|
||||
{form.id && (
|
||||
<label className="field-check">
|
||||
<input type="checkbox" checked={form.active} onChange={e => setForm({ ...form, active: e.target.checked })} />
|
||||
Active
|
||||
</label>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={save}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openTask !== null && <TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={() => detail && reloadDetail(detail.id)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
128
frontend/src/pages/History.tsx
Normal file
128
frontend/src/pages/History.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Search, Download } from 'lucide-react'
|
||||
import type { Task, Category } from '../types'
|
||||
import { can } from '../types'
|
||||
import { fetchHistory, fetchLocations, historyExportUrl, type HistoryFilters } from '../api'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import TaskModal from '../components/TaskModal'
|
||||
import { PriorityBadge, StatusBadge, formatDate } from '../components/shared'
|
||||
|
||||
export default function HistoryPage() {
|
||||
const { user } = useAuth()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [totals, setTotals] = useState<{ count: number; total_cost: string | null; avg_days_to_fix: string | null } | null>(null)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [openTask, setOpenTask] = useState<number | null>(null)
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
|
||||
const [includeTemporary, setIncludeTemporary] = useState(false)
|
||||
|
||||
const filters: HistoryFilters = {
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
category_id: categoryFilter ?? undefined,
|
||||
include_temporary: includeTemporary,
|
||||
}
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchHistory(filters)
|
||||
.then(d => { setTasks(d.tasks); setTotals(d.totals); setError(null) })
|
||||
.catch(err => setError(err.message))
|
||||
}, [q, from, to, categoryFilter, includeTemporary])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(load, q ? 300 : 0) // debounce typing
|
||||
return () => clearTimeout(t)
|
||||
}, [load])
|
||||
useEffect(() => { fetchLocations().then(d => setCategories(d.categories)).catch(() => {}) }, [])
|
||||
|
||||
const showCosts = can(user, 'costs')
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>History</h1>
|
||||
<a className="btn" href={historyExportUrl(filters)}>
|
||||
<Download size={14} strokeWidth={1.75} /> CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="field-row" style={{ flexWrap: 'wrap' }}>
|
||||
<div className="field" style={{ flex: 2, minWidth: 200 }}>
|
||||
<label><Search size={11} strokeWidth={1.75} /> Search</label>
|
||||
<input type="text" value={q} onChange={e => setQ(e.target.value)} placeholder="Title, description or location…" />
|
||||
</div>
|
||||
<div className="field"><label>From</label><input type="date" value={from} onChange={e => setFrom(e.target.value)} /></div>
|
||||
<div className="field"><label>To</label><input type="date" value={to} onChange={e => setTo(e.target.value)} /></div>
|
||||
</div>
|
||||
|
||||
<div className="chip-bar">
|
||||
{categories.map(c => (
|
||||
<button key={c.id} className={`chip ${categoryFilter === c.id ? 'active' : ''}`} onClick={() => setCategoryFilter(categoryFilter === c.id ? null : c.id)}>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
<button className={`chip ${includeTemporary ? 'active' : ''}`} onClick={() => setIncludeTemporary(!includeTemporary)}>
|
||||
Include temporary fixes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{totals && (
|
||||
<div className="stats-strip">
|
||||
<div className="stat-box"><div className="stat-value">{totals.count}</div><div className="stat-label">Fixed</div></div>
|
||||
{showCosts && totals.total_cost != null && (
|
||||
<div className="stat-box"><div className="stat-value">£{totals.total_cost}</div><div className="stat-label">Total cost</div></div>
|
||||
)}
|
||||
{totals.avg_days_to_fix != null && (
|
||||
<div className="stat-box"><div className="stat-value">{totals.avg_days_to_fix}</div><div className="stat-label">Avg days to fix</div></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Location</th>
|
||||
<th>Priority</th>
|
||||
<th>Status</th>
|
||||
<th>Reported</th>
|
||||
<th>Completed</th>
|
||||
<th>By</th>
|
||||
{showCosts && <th>Cost</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map(t => (
|
||||
<tr key={t.id} className="clickable" onClick={() => setOpenTask(t.id)}>
|
||||
<td>{t.title}</td>
|
||||
<td>{t.location_name}</td>
|
||||
<td><PriorityBadge priority={t.priority} /></td>
|
||||
<td><StatusBadge status={t.status} /></td>
|
||||
<td>{formatDate(t.created_at)}</td>
|
||||
<td>{formatDate(t.completed_at)}</td>
|
||||
<td>{t.completed_by_name || '—'}</td>
|
||||
{showCosts && <td>{t.cost != null ? `£${t.cost}` : '—'}</td>}
|
||||
</tr>
|
||||
))}
|
||||
{tasks.length === 0 && (
|
||||
<tr><td colSpan={showCosts ? 8 : 7} className="empty-state">No fixed tasks match.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{openTask !== null && (
|
||||
<TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={load} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
frontend/src/pages/Locations.tsx
Normal file
190
frontend/src/pages/Locations.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Plus, RefreshCw, X } from 'lucide-react'
|
||||
import type { Category, Location } from '../types'
|
||||
import {
|
||||
fetchLocations, createLocation, updateLocation, syncNewbookRooms,
|
||||
createCategory, updateCategory, deleteCategory,
|
||||
} from '../api'
|
||||
|
||||
export default function Locations() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newCategoryId, setNewCategoryId] = useState<number | ''>('')
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const [editLoc, setEditLoc] = useState<Location | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations) }).catch(err => setError(err.message))
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function addLocation() {
|
||||
if (!newName.trim() || !newCategoryId) { setError('Location name and category required'); return }
|
||||
try {
|
||||
await createLocation({ name: newName.trim(), category_id: newCategoryId as number })
|
||||
setNewName('')
|
||||
setError(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function addCategory() {
|
||||
if (!newCatName.trim()) return
|
||||
try {
|
||||
await createCategory({ name: newCatName.trim(), sort_order: categories.length + 1 })
|
||||
setNewCatName('')
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function doSync() {
|
||||
setSyncing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await syncNewbookRooms()
|
||||
setInfo(`NewBook sync: ${r.created} added, ${r.updated} updated (${r.total} rooms)`)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sync failed')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Locations</h1>
|
||||
<button className="btn" onClick={doSync} disabled={syncing}>
|
||||
<RefreshCw size={14} strokeWidth={1.75} /> {syncing ? 'Syncing…' : 'Sync NewBook rooms'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{info && <div className="card" style={{ background: 'var(--ok-bg)' }}>{info}</div>}
|
||||
|
||||
{/* Add manual location */}
|
||||
<div className="card">
|
||||
<div className="field-row" style={{ alignItems: 'flex-end' }}>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>New location</label>
|
||||
<input type="text" value={newName} onChange={e => setNewName(e.target.value)} placeholder="e.g. Main kitchen, Bar, Terrace" />
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>Category</label>
|
||||
<select value={newCategoryId} onChange={e => setNewCategoryId(e.target.value ? parseInt(e.target.value) : '')}>
|
||||
<option value="">Select…</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={addLocation} style={{ marginBottom: 1 }}>
|
||||
<Plus size={14} strokeWidth={1.75} /> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.map(c => {
|
||||
const locs = locations.filter(l => l.category_id === c.id)
|
||||
return (
|
||||
<div key={c.id}>
|
||||
<div className="section-title">
|
||||
{c.name} {c.is_rooms && <span className="badge badge-outline">NewBook rooms</span>} ({locs.filter(l => l.active).length})
|
||||
</div>
|
||||
<div className="chip-bar">
|
||||
{locs.map(l => (
|
||||
<button
|
||||
key={l.id}
|
||||
className="chip"
|
||||
style={l.active ? undefined : { opacity: .45, textDecoration: 'line-through' }}
|
||||
title={l.source === 'newbook' ? `NewBook site ${l.newbook_site_id}` : 'Manual location — click to edit'}
|
||||
onClick={() => setEditLoc(l)}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
{locs.length === 0 && <span className="muted" style={{ fontSize: 12.5 }}>none</span>}
|
||||
{c.is_rooms && locs.length === 0 && <span className="muted" style={{ fontSize: 12.5 }}>— run the NewBook sync</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Category management */}
|
||||
<div className="section-title">Categories</div>
|
||||
<div className="card">
|
||||
{categories.map(c => (
|
||||
<div key={c.id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ flex: 1 }}>{c.name}{c.is_rooms ? ' (rooms)' : ''}</span>
|
||||
<button className="btn btn-sm" onClick={() => {
|
||||
const name = window.prompt('Rename category', c.name)
|
||||
if (name && name !== c.name) updateCategory(c.id, { name }).then(load).catch(err => setError(err.message))
|
||||
}}>Rename</button>
|
||||
{!c.is_rooms && (
|
||||
<button className="btn btn-sm" onClick={() => {
|
||||
if (window.confirm(`Delete category "${c.name}"? Only possible when it has no locations.`)) {
|
||||
deleteCategory(c.id).then(load).catch(err => setError(err.message))
|
||||
}
|
||||
}}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="field-row" style={{ alignItems: 'flex-end', marginTop: 10 }}>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>New category</label>
|
||||
<input type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)} placeholder="e.g. Plant Rooms" />
|
||||
</div>
|
||||
<button className="btn" onClick={addCategory} style={{ marginBottom: 1 }}>
|
||||
<Plus size={14} strokeWidth={1.75} /> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit location modal */}
|
||||
{editLoc && (
|
||||
<div className="modal-overlay" onClick={() => setEditLoc(null)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
|
||||
<div className="modal-header">
|
||||
<h2>{editLoc.name}</h2>
|
||||
<button className="modal-close" onClick={() => setEditLoc(null)}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
{editLoc.source === 'newbook' ? (
|
||||
<p className="muted">NewBook room (site {editLoc.newbook_site_id}) — name and status come from the sync.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="field"><label>Name</label>
|
||||
<input type="text" value={editLoc.name} onChange={e => setEditLoc({ ...editLoc, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="field"><label>Category</label>
|
||||
<select value={editLoc.category_id} onChange={e => setEditLoc({ ...editLoc, category_id: parseInt(e.target.value) })}>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<label className="field-check">
|
||||
<input type="checkbox" checked={editLoc.active} onChange={e => setEditLoc({ ...editLoc, active: e.target.checked })} />
|
||||
Active
|
||||
</label>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setEditLoc(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={() => {
|
||||
updateLocation(editLoc.id, {
|
||||
name: editLoc.name, category_id: editLoc.category_id, active: editLoc.active,
|
||||
}).then(() => { setEditLoc(null); load() }).catch(err => setError(err.message))
|
||||
}}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
230
frontend/src/pages/Recurring.tsx
Normal file
230
frontend/src/pages/Recurring.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, X, Play } from 'lucide-react'
|
||||
import type { Template, Category, Location, Asset, Priority } from '../types'
|
||||
import { PRIORITIES, PRIORITY_LABELS, can } from '../types'
|
||||
import { fetchTemplates, createTemplate, updateTemplate, runDueTemplates, fetchLocations, fetchAssets } from '../api'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import AssigneeSelect, { type Assignment } from '../components/AssigneeSelect'
|
||||
import { PriorityBadge, formatDate } from '../components/shared'
|
||||
|
||||
interface TemplateForm {
|
||||
id?: number
|
||||
title: string
|
||||
description: string
|
||||
location_id: number | ''
|
||||
asset_id: number | ''
|
||||
priority: Priority
|
||||
unusable: boolean
|
||||
interval_value: number
|
||||
interval_unit: 'days' | 'weeks' | 'months'
|
||||
next_due: string
|
||||
template_notes: string
|
||||
active: boolean
|
||||
assignment: Assignment
|
||||
}
|
||||
|
||||
const EMPTY: TemplateForm = {
|
||||
title: '', description: '', location_id: '', asset_id: '', priority: 'medium', unusable: false,
|
||||
interval_value: 1, interval_unit: 'months', next_due: '', template_notes: '', active: true,
|
||||
assignment: { assigned_type: 'staff', assigned_to: null, assigned_to_name: null, contractor_id: null },
|
||||
}
|
||||
|
||||
export default function Recurring() {
|
||||
const { user } = useAuth()
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [assets, setAssets] = useState<Asset[]>([])
|
||||
const [form, setForm] = useState<TemplateForm | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
|
||||
const canManage = can(user, 'manage_templates')
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchTemplates().then(setTemplates).catch(err => setError(err.message))
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => {
|
||||
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations.filter(l => l.active)) }).catch(() => {})
|
||||
fetchAssets().then(setAssets).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const grouped = useMemo(() => categories.map(c => ({
|
||||
category: c,
|
||||
locations: locations.filter(l => l.category_id === c.id),
|
||||
})).filter(g => g.locations.length), [categories, locations])
|
||||
|
||||
const formAssets = useMemo(
|
||||
() => assets.filter(a => form && a.location_id === form.location_id),
|
||||
[assets, form?.location_id]
|
||||
)
|
||||
|
||||
async function save() {
|
||||
if (!form || !form.title.trim() || !form.location_id || !form.next_due) {
|
||||
setError('Title, location and first due date are required')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = {
|
||||
title: form.title.trim(),
|
||||
description: form.description || null,
|
||||
location_id: form.location_id,
|
||||
asset_id: form.asset_id || null,
|
||||
priority: form.priority,
|
||||
unusable: form.unusable,
|
||||
interval_value: form.interval_value,
|
||||
interval_unit: form.interval_unit,
|
||||
next_due: form.next_due,
|
||||
template_notes: form.template_notes,
|
||||
active: form.active,
|
||||
assigned_type: form.assignment.assigned_type,
|
||||
assigned_to: form.assignment.assigned_to,
|
||||
assigned_to_name: form.assignment.assigned_to_name,
|
||||
contractor_id: form.assignment.contractor_id,
|
||||
}
|
||||
if (form.id) await updateTemplate(form.id, body)
|
||||
else await createTemplate(body)
|
||||
setForm(null)
|
||||
setError(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Recurring tasks</h1>
|
||||
{canManage && (
|
||||
<>
|
||||
<button className="btn" onClick={() => runDueTemplates().then(r => { setInfo(`Spawned ${r.spawned} due task(s)`); load() }).catch(err => setError(err.message))}>
|
||||
<Play size={14} strokeWidth={1.75} /> Run due now
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => setForm(EMPTY)}>
|
||||
<Plus size={14} strokeWidth={1.75} /> New template
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{info && <div className="card" style={{ background: 'var(--ok-bg)' }}>{info}</div>}
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr><th>Task</th><th>Location</th><th>Asset</th><th>Priority</th><th>Every</th><th>Next due</th><th>Allocated</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map(t => (
|
||||
<tr key={t.id} className={canManage ? 'clickable' : ''} onClick={() => canManage && setForm({
|
||||
id: t.id, title: t.title, description: t.description || '',
|
||||
location_id: t.location_id, asset_id: t.asset_id || '', priority: t.priority,
|
||||
unusable: t.unusable, interval_value: t.interval_value, interval_unit: t.interval_unit,
|
||||
next_due: t.next_due.slice(0, 10), template_notes: t.template_notes, active: t.active,
|
||||
assignment: {
|
||||
assigned_type: t.assigned_type, assigned_to: t.assigned_to,
|
||||
assigned_to_name: t.assigned_to_name, contractor_id: t.contractor_id,
|
||||
},
|
||||
})}>
|
||||
<td>{t.title}</td>
|
||||
<td>{t.location_name}</td>
|
||||
<td>{t.asset_name || '—'}</td>
|
||||
<td><PriorityBadge priority={t.priority} /></td>
|
||||
<td>{t.interval_value} {t.interval_unit}</td>
|
||||
<td className={new Date(t.next_due) <= new Date() ? 'overdue' : ''}>{formatDate(t.next_due)}</td>
|
||||
<td>{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}</td>
|
||||
<td>{!t.active && <span className="badge badge-outline">paused</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{templates.length === 0 && (
|
||||
<tr><td colSpan={8} className="empty-state">No recurring templates — set up fire alarm tests, boiler service, legionella flushing…</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{form && (
|
||||
<div className="modal-overlay" onClick={() => setForm(null)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{form.id ? 'Edit template' : 'New recurring template'}</h2>
|
||||
<button className="modal-close" onClick={() => setForm(null)}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
|
||||
<div className="field"><label>Title</label>
|
||||
<input type="text" value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="e.g. Fire alarm weekly test" autoFocus />
|
||||
</div>
|
||||
<div className="field"><label>Location</label>
|
||||
<select value={form.location_id} onChange={e => setForm({ ...form, location_id: e.target.value ? parseInt(e.target.value) : '', asset_id: '' })}>
|
||||
<option value="">Select location…</option>
|
||||
{grouped.map(g => (
|
||||
<optgroup key={g.category.id} label={g.category.name}>
|
||||
{g.locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{formAssets.length > 0 && (
|
||||
<div className="field"><label>Asset (optional)</label>
|
||||
<select value={form.asset_id} onChange={e => setForm({ ...form, asset_id: e.target.value ? parseInt(e.target.value) : '' })}>
|
||||
<option value="">None</option>
|
||||
{formAssets.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="field"><label>Description</label>
|
||||
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="field"><label>Priority</label>
|
||||
<div className="chip-bar" style={{ marginBottom: 0 }}>
|
||||
{PRIORITIES.map(p => (
|
||||
<button key={p} type="button" className={`chip ${form.priority === p ? 'active' : ''}`} onClick={() => setForm({ ...form, priority: p })}>
|
||||
{PRIORITY_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Repeat every</label>
|
||||
<input type="number" min={1} value={form.interval_value} onChange={e => setForm({ ...form, interval_value: parseInt(e.target.value) || 1 })} />
|
||||
</div>
|
||||
<div className="field"><label>Unit</label>
|
||||
<select value={form.interval_unit} onChange={e => setForm({ ...form, interval_unit: e.target.value as TemplateForm['interval_unit'] })}>
|
||||
<option value="days">days</option>
|
||||
<option value="weeks">weeks</option>
|
||||
<option value="months">months</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>{form.id ? 'Next due' : 'First due'}</label>
|
||||
<input type="date" value={form.next_due} onChange={e => setForm({ ...form, next_due: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssigneeSelect value={form.assignment} onChange={a => setForm({ ...form, assignment: a })} />
|
||||
|
||||
<div className="field">
|
||||
<label>Template notes (shown on every occurrence)</label>
|
||||
<textarea value={form.template_notes} onChange={e => setForm({ ...form, template_notes: e.target.value })} placeholder="Accumulated tips from previous visits…" />
|
||||
<div className="field-hint">Notes added from a spawned task with “add to template” ticked land here automatically.</div>
|
||||
</div>
|
||||
|
||||
{form.id && (
|
||||
<label className="field-check">
|
||||
<input type="checkbox" checked={form.active} onChange={e => setForm({ ...form, active: e.target.checked })} />
|
||||
Active (untick to pause the schedule)
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={save}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
frontend/src/pages/Settings.tsx
Normal file
123
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Save } from 'lucide-react'
|
||||
import type { AppConfig, AuthUser, Contractor } from '../types'
|
||||
import { fetchConfig, updateConfig, fetchAssignableUsers, fetchContractors } from '../api'
|
||||
|
||||
export default function Settings() {
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [users, setUsers] = useState<AuthUser[]>([])
|
||||
const [contractors, setContractors] = useState<Contractor[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig().then(setConfig).catch(err => setError(err.message))
|
||||
fetchAssignableUsers().then(setUsers).catch(() => {})
|
||||
fetchContractors().then(setContractors).catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (!config) return <div className="page">{error ? <div className="error-banner">{error}</div> : 'Loading…'}</div>
|
||||
|
||||
async function saveAll() {
|
||||
if (!config) return
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
try {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
await updateConfig(key, value)
|
||||
}
|
||||
setSaved(true)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
}
|
||||
}
|
||||
|
||||
const set = (patch: Partial<AppConfig>) => { setConfig({ ...config, ...patch }); setSaved(false) }
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 640 }}>
|
||||
<div className="page-header">
|
||||
<h1>Settings</h1>
|
||||
<button className="btn btn-primary" onClick={saveAll}>
|
||||
<Save size={14} strokeWidth={1.75} /> Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{saved && <div className="card" style={{ background: 'var(--ok-bg)' }}>Settings saved.</div>}
|
||||
|
||||
<div className="section-title">Default allocation</div>
|
||||
<div className="card">
|
||||
<div className="field">
|
||||
<label>Default allocation type</label>
|
||||
<select value={config.default_assigned_type} onChange={e => set({ default_assigned_type: e.target.value as AppConfig['default_assigned_type'] })}>
|
||||
<option value="staff">Staff</option>
|
||||
<option value="contractor">Contractor</option>
|
||||
</select>
|
||||
</div>
|
||||
{config.default_assigned_type === 'staff' ? (
|
||||
<div className="field">
|
||||
<label>Default staff member</label>
|
||||
<select
|
||||
value={config.default_assignee}
|
||||
onChange={e => {
|
||||
const u = users.find(x => x.email === e.target.value)
|
||||
set({ default_assignee: e.target.value, default_assignee_name: u?.name || '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{users.map(u => <option key={u.email} value={u.email}>{u.name}</option>)}
|
||||
</select>
|
||||
<div className="field-hint">New tasks are allocated here unless the reporter picks someone else.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="field">
|
||||
<label>Default contractor</label>
|
||||
<select
|
||||
value={config.default_contractor_id ?? ''}
|
||||
onChange={e => set({ default_contractor_id: e.target.value ? parseInt(e.target.value) : null })}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{contractors.map(c => <option key={c.id} value={c.id}>{c.name}{c.company ? ` — ${c.company}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-title">Notifications</div>
|
||||
<div className="card">
|
||||
<label className="field-check" style={{ marginBottom: 10 }}>
|
||||
<input type="checkbox" checked={config.notify_on_assign} onChange={e => set({ notify_on_assign: e.target.checked })} />
|
||||
Email the assignee when a task is allocated or reallocated
|
||||
</label>
|
||||
<label className="field-check" style={{ marginBottom: 10 }}>
|
||||
<input type="checkbox" checked={config.notify_on_urgent} onChange={e => set({ notify_on_urgent: e.target.checked })} />
|
||||
Email when an urgent task is logged
|
||||
</label>
|
||||
<div className="field">
|
||||
<label>Urgent notification address</label>
|
||||
<input type="email" value={config.urgent_notify_email} onChange={e => set({ urgent_notify_email: e.target.value })} placeholder="maintenance@…" />
|
||||
</div>
|
||||
<div className="field-hint">Uses the stack SMTP settings (Settings app → integrations).</div>
|
||||
</div>
|
||||
|
||||
<div className="section-title">NewBook room blocking</div>
|
||||
<div className="card">
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Status set when blocking a room</label>
|
||||
<input type="text" value={config.newbook_block_status} onChange={e => set({ newbook_block_status: e.target.value })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Status set when releasing a room</label>
|
||||
<input type="text" value={config.newbook_unblock_status} onChange={e => set({ newbook_unblock_status: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-hint">
|
||||
Must match NewBook site status values exactly (e.g. Maintenance, Dirty). Blocking is always an explicit
|
||||
per-task confirmation — never automatic.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
frontend/src/pages/Summary.tsx
Normal file
159
frontend/src/pages/Summary.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, RefreshCw, Camera, BedDouble } from 'lucide-react'
|
||||
import type { Task, Category, Location, AppConfig, TaskStatus } from '../types'
|
||||
import { STATUS_LABELS, can } from '../types'
|
||||
import { fetchTasks, fetchLocations, fetchConfig } from '../api'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import NewTaskModal from '../components/NewTaskModal'
|
||||
import TaskModal from '../components/TaskModal'
|
||||
import { PriorityBadge, StatusBadge, UnusableBadge, ageLabel, formatDate } from '../components/shared'
|
||||
|
||||
const OPEN_STATUSES: TaskStatus[] = ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix']
|
||||
|
||||
export default function Summary() {
|
||||
const { user } = useAuth()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
|
||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
|
||||
const [mineOnly, setMineOnly] = useState(false)
|
||||
const [unoccupiedOnly, setUnoccupiedOnly] = useState(false)
|
||||
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [openTask, setOpenTask] = useState<number | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
fetchTasks({
|
||||
status: statusFilter ?? OPEN_STATUSES.join(','),
|
||||
category_id: categoryFilter ?? undefined,
|
||||
assigned_to: mineOnly ? user.email : undefined,
|
||||
unoccupied: unoccupiedOnly,
|
||||
})
|
||||
.then(t => { setTasks(t); setError(null) })
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [statusFilter, categoryFilter, mineOnly, unoccupiedOnly, user.email])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => {
|
||||
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations) }).catch(() => {})
|
||||
fetchConfig().then(setConfig).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Partial<Record<TaskStatus, number>> = {}
|
||||
for (const t of tasks) c[t.status] = (c[t.status] || 0) + 1
|
||||
return c
|
||||
}, [tasks])
|
||||
|
||||
const roomsCategoryExists = categories.some(c => c.is_rooms)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Open maintenance</h1>
|
||||
<button className="btn" onClick={load} disabled={loading}>
|
||||
<RefreshCw size={14} strokeWidth={1.75} /> Refresh
|
||||
</button>
|
||||
{can(user, 'report') && (
|
||||
<button className="btn btn-primary" onClick={() => setShowNew(true)}>
|
||||
<Plus size={14} strokeWidth={1.75} /> Report fault
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="chip-bar">
|
||||
<button className={`chip ${statusFilter === null ? 'active' : ''}`} onClick={() => setStatusFilter(null)}>
|
||||
All open
|
||||
</button>
|
||||
{OPEN_STATUSES.map(s => (
|
||||
<button key={s} className={`chip ${statusFilter === s ? 'active' : ''}`} onClick={() => setStatusFilter(statusFilter === s ? null : s)}>
|
||||
{STATUS_LABELS[s]}{counts[s] ? ` (${counts[s]})` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="chip-bar">
|
||||
{categories.map(c => (
|
||||
<button key={c.id} className={`chip ${categoryFilter === c.id ? 'active' : ''}`} onClick={() => setCategoryFilter(categoryFilter === c.id ? null : c.id)}>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
<button className={`chip ${mineOnly ? 'active' : ''}`} onClick={() => setMineOnly(!mineOnly)}>
|
||||
Mine
|
||||
</button>
|
||||
{roomsCategoryExists && (
|
||||
<button
|
||||
className={`chip ${unoccupiedOnly ? 'active' : ''}`}
|
||||
title="Only rooms with no in-house guest right now (live from NewBook)"
|
||||
onClick={() => setUnoccupiedOnly(!unoccupiedOnly)}
|
||||
>
|
||||
<BedDouble size={13} strokeWidth={1.75} /> Unoccupied rooms only
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 && !loading && (
|
||||
<div className="empty-state">No open tasks match these filters.</div>
|
||||
)}
|
||||
|
||||
{tasks.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
|
||||
onClick={() => setOpenTask(t.id)}
|
||||
>
|
||||
<div className="task-card-main">
|
||||
<div className="task-card-title">
|
||||
{t.title}
|
||||
{t.unusable && <UnusableBadge />}
|
||||
{t.newbook_blocked && <span className="badge badge-outline">NB blocked</span>}
|
||||
{t.template_id && <span className="badge badge-outline">Recurring</span>}
|
||||
</div>
|
||||
<div className="task-card-meta">
|
||||
<span>{t.location_name}</span>
|
||||
<span>{t.category_name}</span>
|
||||
{t.asset_name && <span>{t.asset_name}</span>}
|
||||
<span>{ageLabel(t.created_at)} old</span>
|
||||
{t.due_date && (
|
||||
<span className={new Date(t.due_date) < new Date() ? 'overdue' : ''}>
|
||||
due {formatDate(t.due_date)}
|
||||
</span>
|
||||
)}
|
||||
{t.hold_until && <span>held until {formatDate(t.hold_until)}</span>}
|
||||
{t.photo_count > 0 && <span><Camera size={12} strokeWidth={1.75} style={{ verticalAlign: -2 }} /> {t.photo_count}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-card-side">
|
||||
<PriorityBadge priority={t.priority} />
|
||||
<StatusBadge status={t.status} />
|
||||
<span className="muted" style={{ fontSize: 11.5 }}>
|
||||
{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showNew && (
|
||||
<NewTaskModal
|
||||
categories={categories}
|
||||
locations={locations}
|
||||
config={config}
|
||||
onClose={() => setShowNew(false)}
|
||||
onCreated={load}
|
||||
/>
|
||||
)}
|
||||
{openTask !== null && (
|
||||
<TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={load} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
236
frontend/src/types.ts
Normal file
236
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
|
||||
|
||||
export type TaskStatus =
|
||||
| 'submitted'
|
||||
| 'in_progress'
|
||||
| 'hold_parts'
|
||||
| 'hold_scheduled'
|
||||
| 'temporary_fix'
|
||||
| 'fixed'
|
||||
|
||||
export type AssignedType = 'staff' | 'contractor'
|
||||
export type PhotoStage = 'report' | 'progress' | 'resolution'
|
||||
|
||||
export const PRIORITIES: Priority[] = ['low', 'medium', 'high', 'urgent']
|
||||
|
||||
export const PRIORITY_LABELS: Record<Priority, string> = {
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
urgent: 'Urgent',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
submitted: 'Submitted',
|
||||
in_progress: 'In Progress',
|
||||
hold_parts: 'Hold — Parts Ordered',
|
||||
hold_scheduled: 'Hold — Later Date',
|
||||
temporary_fix: 'Temporary Fix',
|
||||
fixed: 'Fixed',
|
||||
}
|
||||
|
||||
// Legal transitions mirrored from the backend (task-core.js) so the UI only
|
||||
// offers valid moves; resolve statuses go through the resolve dialog.
|
||||
export const TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
|
||||
submitted: ['in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
|
||||
in_progress: ['submitted', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
|
||||
hold_parts: ['submitted', 'in_progress', 'hold_scheduled', 'temporary_fix', 'fixed'],
|
||||
hold_scheduled: ['submitted', 'in_progress', 'hold_parts', 'temporary_fix', 'fixed'],
|
||||
temporary_fix: ['submitted', 'in_progress', 'fixed'],
|
||||
fixed: ['submitted'],
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
sort_order: number
|
||||
is_rooms: boolean
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
id: number
|
||||
name: string
|
||||
category_id: number
|
||||
category_name: string
|
||||
is_rooms: boolean
|
||||
source: 'manual' | 'newbook'
|
||||
newbook_site_id: string | null
|
||||
active: boolean
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
location_id: number
|
||||
location_name: string
|
||||
location_source: 'manual' | 'newbook'
|
||||
newbook_site_id: string | null
|
||||
category_id: number
|
||||
category_name: string
|
||||
is_rooms: boolean
|
||||
asset_id: number | null
|
||||
asset_name: string | null
|
||||
template_id: number | null
|
||||
priority: Priority
|
||||
status: TaskStatus
|
||||
unusable: boolean
|
||||
newbook_blocked: boolean
|
||||
hold_until: string | null
|
||||
due_date: string | null
|
||||
assigned_type: AssignedType
|
||||
assigned_to: string | null
|
||||
assigned_to_name: string | null
|
||||
contractor_id: number | null
|
||||
contractor_name: string | null
|
||||
contractor_company: string | null
|
||||
created_by: string | null
|
||||
created_by_name: string | null
|
||||
completed_by: string | null
|
||||
completed_by_name: string | null
|
||||
completed_at: string | null
|
||||
cost?: string | null
|
||||
cost_notes?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
photo_count: number
|
||||
days_to_fix?: number
|
||||
}
|
||||
|
||||
export interface TaskPhoto {
|
||||
id: number
|
||||
task_id: number
|
||||
file_name: string
|
||||
file_path: string
|
||||
mime_type: string
|
||||
stage: PhotoStage
|
||||
uploaded_by: string
|
||||
uploaded_at: string
|
||||
}
|
||||
|
||||
export interface TaskEvent {
|
||||
id: number
|
||||
task_id: number
|
||||
event_type: string
|
||||
from_status: TaskStatus | null
|
||||
to_status: TaskStatus | null
|
||||
note: string | null
|
||||
user_name: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TaskDetail extends Task {
|
||||
photos: TaskPhoto[]
|
||||
events: TaskEvent[]
|
||||
template: {
|
||||
id: number
|
||||
title: string
|
||||
interval_value: number
|
||||
interval_unit: string
|
||||
next_due: string
|
||||
active: boolean
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface Asset {
|
||||
id: number
|
||||
name: string
|
||||
location_id: number
|
||||
location_name: string
|
||||
category_name?: string
|
||||
make_model: string | null
|
||||
serial_no: string | null
|
||||
install_date: string | null
|
||||
notes: string | null
|
||||
active: boolean
|
||||
open_tasks?: number
|
||||
recurring_count?: number
|
||||
}
|
||||
|
||||
export interface AssetDetail extends Asset {
|
||||
tasks: Array<Pick<Task, 'id' | 'title' | 'status' | 'priority' | 'created_at' | 'completed_at' | 'completed_by_name'>>
|
||||
templates: Array<Pick<Template, 'id' | 'title' | 'interval_value' | 'interval_unit' | 'next_due' | 'active'>>
|
||||
}
|
||||
|
||||
export interface Contractor {
|
||||
id: number
|
||||
name: string
|
||||
company: string | null
|
||||
phone: string | null
|
||||
email: string | null
|
||||
address: string | null
|
||||
notes: string | null
|
||||
active: boolean
|
||||
open_tasks?: number
|
||||
doc_count?: number
|
||||
earliest_doc_expiry?: string | null
|
||||
}
|
||||
|
||||
export interface ContractorDoc {
|
||||
id: number
|
||||
contractor_id: number
|
||||
file_name: string
|
||||
file_path: string
|
||||
mime_type: string
|
||||
doc_type: string | null
|
||||
expiry_date: string | null
|
||||
uploaded_at: string
|
||||
}
|
||||
|
||||
export interface ContractorDetail extends Contractor {
|
||||
docs: ContractorDoc[]
|
||||
tasks: Array<Pick<Task, 'id' | 'title' | 'status' | 'priority' | 'created_at' | 'completed_at' | 'location_name'>>
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
location_id: number
|
||||
location_name: string
|
||||
asset_id: number | null
|
||||
asset_name: string | null
|
||||
priority: Priority
|
||||
unusable: boolean
|
||||
assigned_type: AssignedType
|
||||
assigned_to: string | null
|
||||
assigned_to_name: string | null
|
||||
contractor_id: number | null
|
||||
contractor_name: string | null
|
||||
interval_value: number
|
||||
interval_unit: 'days' | 'weeks' | 'months'
|
||||
next_due: string
|
||||
template_notes: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
default_assigned_type: AssignedType
|
||||
default_assignee: string
|
||||
default_assignee_name: string
|
||||
default_contractor_id: number | null
|
||||
urgent_notify_email: string
|
||||
notify_on_assign: boolean
|
||||
notify_on_urgent: boolean
|
||||
newbook_block_status: string
|
||||
newbook_unblock_status: string
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
user_id: number
|
||||
name: string
|
||||
email: string
|
||||
is_admin: boolean
|
||||
caps: string[] // bare slugs — verify?app=maintenance strips the prefix
|
||||
}
|
||||
|
||||
export function can(user: User, cap: string): boolean {
|
||||
return user.is_admin || user.caps.includes(cap)
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/maintenance/',
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue