diff --git a/CHANGELOG.md b/CHANGELOG.md index d16dc88..034347f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Toutes les versions notables de TechIBase sont documentées ici. Le projet est e ## [Non publié] - **Cycle de maintenance et rappels** : un équipement peut avoir un cycle de maintenance (mensuel, trimestriel, semestriel, annuel, tous les 2 ans). Quand un bon de service de maintenance (généré automatiquement ou prévu à la date de prochaine maintenance) passe à « Terminé », la dernière maintenance est enregistrée et la prochaine est reportée d'autant. L'accueil affiche les listes « Maintenances en retard / à venir (30 j) » et « EPI expirés / bientôt expirés (30 j) ». La tâche quotidienne crée des notifications pour les administrateurs (sans doublon sur 7 jours) et le résumé par courriel (si configuré) inclut les maintenances à venir et les EPI expirés. Nouvelle colonne `equipment.maintenance_interval_months` (ajoutée automatiquement). +- **« Ma journée » (mobile)** : une page `/journee` (entrée « Ma journée » du menu) liste, pour le technicien connecté, ses bons de service en cours, du jour et en retard, plus ceux terminés aujourd'hui, avec le client, l'adresse (ouvre la carte), le téléphone (appel en un geste), les boutons **Démarrer** / **Terminer**, une note rapide ajoutée au journal du client et un lien vers les bons du client. Sur téléphone, un utilisateur non administrateur arrive sur cette page à l'ouverture de l'application. Nouvelles routes `GET /api/work-orders/my-day` et `POST /api/work-orders/:id/status` (réservée à la personne assignée ou à un administrateur). ## [0.0.14] - Mises à jour en un clic (publication corrigée) diff --git a/backend/node_modules b/backend/node_modules new file mode 120000 index 0000000..7a8d888 --- /dev/null +++ b/backend/node_modules @@ -0,0 +1 @@ +/home/user/Techbase/backend/node_modules \ No newline at end of file diff --git a/backend/src/app.js b/backend/src/app.js index 5aeba7e..30b1f74 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -56,6 +56,7 @@ const searchRouter = require('./routes/search'); const workOrdersRouter = require('./routes/workorders'); const notificationsRouter = require('./routes/notifications'); const dashboardRouter = require('./routes/dashboard'); +const myDayRouter = require('./routes/myday'); const adminRouter = require('./routes/admin'); const integrationsRouter = require('./routes/integrations'); @@ -71,6 +72,7 @@ app.use('/api', logbookRouter); app.use('/api', documentsRouter); app.use('/api', searchRouter); app.use('/api', workOrdersRouter); +app.use('/api', myDayRouter); app.use('/api', notificationsRouter); app.use('/api', dashboardRouter); app.use('/api', integrationsRouter); diff --git a/backend/src/routes/myday.js b/backend/src/routes/myday.js new file mode 100644 index 0000000..ce657d1 --- /dev/null +++ b/backend/src/routes/myday.js @@ -0,0 +1,116 @@ +const express = require('express'); +const router = express.Router(); +const { authenticate } = require('../middleware/auth'); +const { validate } = require('../middleware/validate'); +const { serverError } = require('../lib/respond'); +const { rollMaintenanceForward } = require('../lib/reminders'); + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +// "2026-02-31" matches the regexp and Date.parse accepts it (rolls over to March), +// but PostgreSQL rejects it: only a date that round-trips is a real calendar day. +const isCalendarDate = (s) => { + if (!DATE_RE.test(s)) return false; + const t = Date.parse(s); + return !Number.isNaN(t) && new Date(t).toISOString().slice(0, 10) === s; +}; + +const SELECT_DAY = ` + SELECT wo.id, wo.client_id, wo.title, wo.description, wo.status, wo.priority, + to_char(wo.due_date, 'YYYY-MM-DD') AS due_date, wo.completed_at, + c.name AS client_name, c.address AS client_address, c.phone AS client_phone, + e.name AS equipment_name + FROM work_orders wo + JOIN clients c ON c.id = wo.client_id + LEFT JOIN equipment e ON e.id = wo.equipment_id +`; + +// GET /api/work-orders/my-day[?date=YYYY-MM-DD] +// The caller's own work orders for the day: in progress, due today, overdue, and +// those finished today. `date` is the technician's local date (the server may be +// in another time zone); it defaults to the server's current date. `tz` (IANA name, +// e.g. Europe/Paris) says which clock "finished today" is read on: completed_at is a +// server-clock timestamp, so a Québec evening would otherwise land on tomorrow. +router.get('/work-orders/my-day', authenticate, async (req, res) => { + const db = req.app.locals.db; + const { date, tz } = req.query; + if (date !== undefined && (typeof date !== 'string' || !isCalendarDate(date))) { + return res.status(400).json({ error: 'Date invalide (AAAA-MM-JJ attendu)' }); + } + try { + let zone = (await db.query("SELECT current_setting('TimeZone') AS z")).rows[0].z; + if (tz !== undefined) { + const known = typeof tz === 'string' && tz.length <= 64 + && (await db.query('SELECT 1 FROM pg_timezone_names WHERE name = $1', [tz])).rows.length > 0; + if (!known) return res.status(400).json({ error: 'Fuseau horaire invalide' }); + zone = tz; + } + const day = date + || (await db.query("SELECT to_char((NOW() AT TIME ZONE $1)::date, 'YYYY-MM-DD') AS d", [zone])).rows[0].d; + const result = await db.query( + `${SELECT_DAY} + WHERE wo.assigned_to = $1 + AND ( + (wo.status IN ('open', 'assigned', 'in_progress') AND (wo.status = 'in_progress' OR wo.due_date <= $2::date)) + OR (wo.status = 'done' + AND ((wo.completed_at AT TIME ZONE current_setting('TimeZone')) AT TIME ZONE $3)::date = $2::date) + ) + ORDER BY (wo.status = 'done'), + (wo.status <> 'in_progress'), + wo.due_date NULLS LAST, + CASE wo.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, + wo.id`, + [req.user.id, day, zone] + ); + res.json({ + date: day, + workOrders: result.rows.map(r => ({ ...r, overdue: r.status !== 'done' && !!r.due_date && r.due_date < day })), + }); + } catch (err) { + serverError(res, err); + } +}); + +// The one place a one-tap status change is written. Anything that must follow a +// transition (e.g. rolling equipment maintenance dates forward when an order +// becomes 'done') hooks in here, with `before` = the row as it was. +async function applyStatusChange(db, before, status) { + const result = await db.query( + `UPDATE work_orders SET status = $1, updated_at = NOW(), + completed_at = ${status === 'done' ? 'COALESCE(completed_at, NOW())' : 'NULL'} + WHERE id = $2 RETURNING id, status, completed_at`, + [status, before.id] + ); + // Same side effect as PUT /work-orders/:id: completing a maintenance work order + // moves the equipment's next maintenance forward (only on a real transition). + if (status === 'done' && before.status !== 'done') { + await rollMaintenanceForward(db, before.id).catch(err => console.error('Maintenance roll-forward failed:', err)); + } + return result.rows[0]; +} + +// POST /api/work-orders/:id/status — one-tap transition for the assignee (or an admin). +// Unlike PUT /work-orders/:id it touches nothing but the status, so a phone never +// overwrites the other fields with stale values. +router.post('/work-orders/:id/status', authenticate, validate({ + status: { required: true, type: 'string', enum: ['in_progress', 'done'] }, +}), async (req, res) => { + const db = req.app.locals.db; + const { status } = req.body; + // Not an int4: nothing can match, and PostgreSQL would answer 500 instead. + if (!/^\d{1,9}$/.test(req.params.id)) return res.status(404).json({ error: 'Bon de service non trouvé' }); + try { + const found = await db.query('SELECT id, assigned_to, status FROM work_orders WHERE id = $1', [req.params.id]); + if (found.rows.length === 0) return res.status(404).json({ error: 'Bon de service non trouvé' }); + const wo = found.rows[0]; + if (req.user.role !== 'admin' && Number(wo.assigned_to) !== Number(req.user.id)) { + return res.status(403).json({ error: 'Ce bon de service ne vous est pas assigné' }); + } + if (wo.status === 'cancelled') return res.status(409).json({ error: 'Bon de service annulé' }); + res.json(await applyStatusChange(db, wo, status)); + } catch (err) { + serverError(res, err); + } +}); + +module.exports = router; +module.exports.applyStatusChange = applyStatusChange; diff --git a/backend/test/myday.test.js b/backend/test/myday.test.js new file mode 100644 index 0000000..b2384a9 --- /dev/null +++ b/backend/test/myday.test.js @@ -0,0 +1,147 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const request = require('supertest'); +const { skipWithoutPg, createTestDb } = require('./helpers/pg'); + +process.env.JWT_SECRET = 'test-secret'; +const { app } = require('../src/app'); +const { issueToken } = require('../src/lib/token'); + +const tokenFor = (u) => ({ Authorization: `Bearer ${issueToken({ email: `${u.id}@x.fr`, name: `U${u.id}`, role: 'user', ...u })}` }); + +let testDb; +let tech, other, admin, clientId; +const DAY = '2026-06-15'; + +const insert = async (title, { status = 'assigned', due = null, assignee = tech, priority = 'medium', completedAt = null } = {}) => + (await testDb.pool.query( + `INSERT INTO work_orders (client_id, title, status, priority, assigned_to, due_date, completed_at) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, + [clientId, title, status, priority, assignee, due, completedAt] + )).rows[0].id; + +test.before(async () => { + if (skipWithoutPg.skip) return; + testDb = await createTestDb(); + app.locals.db = testDb.pool; + const mk = async (email, role) => (await testDb.pool.query( + "INSERT INTO users (email, password_hash, name, role) VALUES ($1,'x',$1,$2) RETURNING id", [email, role])).rows[0].id; + tech = await mk('tech@x.fr', 'user'); + other = await mk('other@x.fr', 'user'); + admin = await mk('admin@x.fr', 'admin'); + clientId = (await testDb.pool.query( + "INSERT INTO clients (name, address, phone) VALUES ('Acme','1 rue A, Lyon','0102030405') RETURNING id")).rows[0].id; +}); + +test.after(async () => { if (testDb) await testDb.drop(); }); + +test('my-day requires authentication', skipWithoutPg, async () => { + assert.equal((await request(app).get('/api/work-orders/my-day')).status, 401); +}); + +test('my-day rejects a malformed date', skipWithoutPg, async () => { + const res = await request(app).get('/api/work-orders/my-day?date=demain').set(tokenFor({ id: tech })); + assert.equal(res.status, 400); +}); + +test('my-day lists own due, overdue, in-progress and done-today orders, in order', skipWithoutPg, async () => { + await insert('later', { due: '2026-06-20' }); + await insert('no due date'); + await insert('cancelled', { status: 'cancelled', due: DAY }); + await insert('not mine', { assignee: other, due: DAY }); + await insert('done yesterday', { status: 'done', due: DAY, completedAt: '2026-06-14 10:00' }); + await insert('today urgent', { due: DAY, priority: 'urgent' }); + await insert('today low', { due: DAY, priority: 'low' }); + await insert('overdue', { due: '2026-06-10', status: 'open' }); + await insert('doing', { status: 'in_progress', due: '2026-06-30' }); + await insert('done today', { status: 'done', due: DAY, completedAt: '2026-06-15 09:00' }); + + const res = await request(app).get(`/api/work-orders/my-day?date=${DAY}`).set(tokenFor({ id: tech })); + assert.equal(res.status, 200); + assert.equal(res.body.date, DAY); + assert.deepEqual(res.body.workOrders.map(w => w.title), + ['doing', 'overdue', 'today urgent', 'today low', 'done today']); + const overdue = res.body.workOrders.find(w => w.title === 'overdue'); + assert.equal(overdue.overdue, true); + assert.equal(overdue.due_date, '2026-06-10'); + assert.equal(overdue.client_address, '1 rue A, Lyon'); + assert.equal(overdue.client_phone, '0102030405'); + assert.equal(res.body.workOrders.find(w => w.title === 'today low').overdue, false); +}); + +test('status transition: assignee starts then finishes, others are refused', skipWithoutPg, async () => { + const id = await insert('transition', { due: DAY }); + const post = (who, status) => request(app).post(`/api/work-orders/${id}/status`).set(tokenFor(who)).send({ status }); + + assert.equal((await post({ id: other }, 'in_progress')).status, 403); + assert.equal((await post({ id: tech }, 'cancelled')).status, 400); + const started = await post({ id: tech }, 'in_progress'); + assert.equal(started.status, 200); + assert.equal(started.body.status, 'in_progress'); + const done = await post({ id: tech }, 'done'); + assert.equal(done.body.status, 'done'); + assert.ok(done.body.completed_at); + // Reopening (admin) clears the completion date. + assert.equal((await post({ id: admin, role: 'admin' }, 'in_progress')).body.completed_at, null); + assert.equal((await request(app).post('/api/work-orders/999999/status').set(tokenFor({ id: tech })).send({ status: 'done' })).status, 404); +}); + +test('a cancelled order cannot be transitioned', skipWithoutPg, async () => { + const id = await insert('nope', { status: 'cancelled' }); + const res = await request(app).post(`/api/work-orders/${id}/status`).set(tokenFor({ id: tech })).send({ status: 'done' }); + assert.equal(res.status, 409); +}); + +test('my-day rejects an impossible calendar date instead of failing in SQL', skipWithoutPg, async () => { + const res = await request(app).get('/api/work-orders/my-day?date=2026-02-31').set(tokenFor({ id: tech })); + assert.equal(res.status, 400); +}); + +test('my-day reads "finished today" on the technician clock when tz is given', skipWithoutPg, async () => { + const stamp = (await testDb.pool.query( + `SELECT ((TIMESTAMP '2026-07-15 20:00' AT TIME ZONE 'America/Toronto') AT TIME ZONE current_setting('TimeZone')) AS t`)).rows[0].t; + await insert('evening job', { status: 'done', completedAt: stamp }); + const titles = async (tz) => (await request(app).get(`/api/work-orders/my-day?date=2026-07-15&tz=${tz}`) + .set(tokenFor({ id: tech }))).body.workOrders.map(w => w.title); + assert.ok((await titles('America/Toronto')).includes('evening job')); + assert.ok(!(await titles('Pacific/Auckland')).includes('evening job')); + assert.equal((await request(app).get('/api/work-orders/my-day?tz=Mars/Base').set(tokenFor({ id: tech }))).status, 400); +}); + +test('status transition on a non-numeric or oversized id is a 404, not a 500', skipWithoutPg, async () => { + for (const id of ['abc', '99999999999']) { + const res = await request(app).post(`/api/work-orders/${id}/status`).set(tokenFor({ id: tech })).send({ status: 'done' }); + assert.equal(res.status, 404); + } +}); + +test('finishing twice keeps the first completion time', skipWithoutPg, async () => { + const id = await insert('twice', { status: 'in_progress', due: DAY }); + const post = () => request(app).post(`/api/work-orders/${id}/status`).set(tokenFor({ id: tech })).send({ status: 'done' }); + const first = (await post()).body.completed_at; + assert.equal((await post()).body.completed_at, first); +}); + +test('finishing a maintenance order from the day view rolls the equipment forward (once)', skipWithoutPg, async () => { + const q = (sql, p) => testDb.pool.query(sql, p); + const eq = (await q( + `INSERT INTO equipment (client_id, name, next_maintenance, maintenance_interval_months) + VALUES ($1,'Onduleur', CURRENT_DATE, 6) RETURNING id`, [clientId])).rows[0].id; + const wo = (await q( + `INSERT INTO work_orders (client_id, equipment_id, title, status, assigned_to, due_date, auto_generated) + VALUES ($1,$2,'Maintenance','assigned',$3,CURRENT_DATE,true) RETURNING id`, [clientId, eq, tech])).rows[0].id; + const state = async () => (await q( + `SELECT to_char(last_maintenance,'YYYY-MM-DD') AS last, to_char(next_maintenance,'YYYY-MM-DD') AS next, + to_char(CURRENT_DATE,'YYYY-MM-DD') AS today, + to_char(CURRENT_DATE + interval '6 months','YYYY-MM-DD') AS expected FROM equipment WHERE id = $1`, [eq])).rows[0]; + + const res = await request(app).post(`/api/work-orders/${wo}/status`).set(tokenFor({ id: tech })).send({ status: 'done' }); + assert.equal(res.status, 200); + const first = await state(); + assert.equal(first.last, first.today); + assert.equal(first.next, first.expected); + + // Re-finishing the same order must not push the date forward again. + await request(app).post(`/api/work-orders/${wo}/status`).set(tokenFor({ id: tech })).send({ status: 'done' }); + assert.equal((await state()).next, first.expected); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 47e67c8..0d2c838 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,6 @@ import React, { Suspense, lazy } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuth } from './contexts/AuthContext'; import ProtectedRoute from './components/ProtectedRoute'; import Layout from './components/Layout'; @@ -11,6 +12,20 @@ const ClientDetail = lazy(() => import('./pages/ClientDetail')); const WorkOrders = lazy(() => import('./pages/WorkOrders')); const Procedures = lazy(() => import('./pages/Procedures')); const Admin = lazy(() => import('./pages/Admin')); +const Journee = lazy(() => import('./pages/Journee')); + +// On a phone, a technician lands on "Ma journée" once per browser session; +// "Accueil" stays reachable from the menu afterwards (Journee sets the flag on mount). +function HomeOrDay() { + const { user } = useAuth(); + let go = false; + try { + go = user?.role !== 'admin' + && window.matchMedia('(max-width: 767px)').matches + && !sessionStorage.getItem('journee-landed'); + } catch { /* storage or matchMedia unavailable: show the home page */ } + return go ? : ; +} export default function App() { return ( @@ -23,7 +38,17 @@ export default function App() { element={ - + + + + } + /> + + + } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 5f2a71a..4b35291 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -4,6 +4,7 @@ import { useAuth } from '../contexts/AuthContext'; const navItems = [ { to: '/', label: 'Accueil', icon: '🏠' }, + { to: '/journee', label: 'Ma journée', icon: '📅' }, { to: '/clients', label: 'Clients', icon: '👥' }, { to: '/work-orders', label: 'Bons de service', icon: '🛠️' }, { to: '/procedures', label: 'Procédures', icon: '📋' }, diff --git a/frontend/src/pages/ClientDetail.jsx b/frontend/src/pages/ClientDetail.jsx index e38a255..53a92cb 100644 --- a/frontend/src/pages/ClientDetail.jsx +++ b/frontend/src/pages/ClientDetail.jsx @@ -907,7 +907,7 @@ export default function ClientDetail() { const navigate = useNavigate(); const { user } = useAuth(); const [client, setClient] = useState(null); - const [activeTab, setActiveTab] = useState('equipment'); + const [activeTab, setActiveTab] = useState(() => new URLSearchParams(window.location.search).get('tab') || 'equipment'); const [editModal, setEditModal] = useState(false); const [loading, setLoading] = useState(true); const visibleTabs = TABS.filter(t => !t.adminOnly || user?.role === 'admin'); @@ -927,7 +927,10 @@ export default function ClientDetail() { ); } - const ActiveComponent = visibleTabs.find(t => t.id === activeTab)?.component; + // An unknown or admin-only ?tab= (stale link, non-admin) falls back to the first tab + // instead of showing an empty panel with no tab selected. + const shownTab = visibleTabs.find(t => t.id === activeTab) || visibleTabs[0]; + const ActiveComponent = shownTab?.component; const clientFields = [ { key: 'name', label: 'Nom *', required: true }, @@ -1005,7 +1008,7 @@ export default function ClientDetail() { key={tab.id} onClick={() => setActiveTab(tab.id)} className={`px-5 py-3 text-sm font-medium whitespace-nowrap transition-colors ${ - activeTab === tab.id + shownTab?.id === tab.id ? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 bg-blue-50/50 dark:bg-blue-900/10' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800' }`} diff --git a/frontend/src/pages/Journee.jsx b/frontend/src/pages/Journee.jsx new file mode 100644 index 0000000..c5828a3 --- /dev/null +++ b/frontend/src/pages/Journee.jsx @@ -0,0 +1,291 @@ +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { Link } from 'react-router-dom'; +import axios from 'axios'; +import { formatDate } from '../lib/dates.mjs'; + +const STATUS_LABELS = { open: 'Ouvert', assigned: 'Assigné', in_progress: 'En cours', done: 'Terminé' }; +const STATUS_BADGE = { + open: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', + assigned: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', + in_progress: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + done: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', +}; +const PRIORITY_LABELS = { low: 'Basse', medium: 'Moyenne', high: 'Haute', urgent: 'Urgente' }; +const PRIORITY_COLORS = { + low: 'text-gray-500 dark:text-gray-400', + medium: 'text-blue-600 dark:text-blue-400', + high: 'text-orange-600 dark:text-orange-400', + urgent: 'text-red-600 dark:text-red-400 font-semibold', +}; + +// The technician's calendar day, not the server's: "YYYY-MM-DD" from the local clock. +function localDate() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +const mapsUrl = (address) => `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`; +// Digits only (a leading + is kept); null when nothing dialable, e.g. "n/a". +const telHref = (phone) => { + const digits = phone.replace(/[^\d]/g, ''); + return digits ? `tel:${phone.trim().startsWith('+') ? '+' : ''}${digits}` : null; +}; +const timeZone = () => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return undefined; } }; + +const TOUCH = 'min-h-[44px] inline-flex items-center justify-center rounded-lg text-sm font-medium px-4 transition-colors'; +const NEUTRAL_BTN = `${TOUCH} bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700`; + +function WorkOrderCard({ wo, onChanged, onError }) { + const [busy, setBusy] = useState(false); + const [noteOpen, setNoteOpen] = useState(false); + const [note, setNote] = useState(''); + const [noteSaved, setNoteSaved] = useState(false); + + const transition = async (status) => { + setBusy(true); + try { + await axios.post(`/api/work-orders/${wo.id}/status`, { status }); + await onChanged(); + } catch (err) { + onError(err.response?.data?.error || 'Impossible de changer le statut'); + } finally { + setBusy(false); + } + }; + + const saveNote = async (e) => { + e.preventDefault(); + if (!note.trim()) return; + setBusy(true); + try { + await axios.post(`/api/clients/${wo.client_id}/logbook`, { + title: `Note : ${wo.title}`.slice(0, 255), + content: note.trim(), + type: 'note', + }); + setNote(''); + setNoteOpen(false); + setNoteSaved(true); + setTimeout(() => setNoteSaved(false), 3000); + } catch (err) { + onError(err.response?.data?.error || "Impossible d'enregistrer la note"); + } finally { + setBusy(false); + } + }; + + const done = wo.status === 'done'; + const link = 'text-blue-700 dark:text-blue-400 underline underline-offset-2'; + + return ( +
  • +
    + + {STATUS_LABELS[wo.status] || wo.status} + + {wo.overdue && ( + + En retard · {formatDate(wo.due_date)} + + )} + {PRIORITY_LABELS[wo.priority]} +
    + +

    {wo.title}

    +

    + {wo.client_name} + {wo.equipment_name && · {wo.equipment_name}} +

    + {wo.description && ( +

    {wo.description}

    + )} + + {(wo.client_address || wo.client_phone) && ( +
    + {wo.client_address && ( + + + {wo.client_address} + + )} + {wo.client_phone && (telHref(wo.client_phone) + ? + + {wo.client_phone} + + :

    + {wo.client_phone} +

    )} +
    + )} + + {noteOpen && ( +
    + +