From 938cacbcd3dcf58921f673c3a661f7e554c0ae27 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 21:40:28 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Add=20technician=20day=20view=20(Ma=20journ?= =?UTF-8?q?=C3=A9e)=20with=20my-day=20and=20status=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01StC4huMbM6BRB9r4XFXvXv --- CHANGELOG.md | 2 + backend/src/app.js | 2 + backend/src/routes/myday.js | 82 +++++++++ backend/test/myday.test.js | 93 ++++++++++ frontend/src/App.jsx | 27 ++- frontend/src/components/Sidebar.jsx | 1 + frontend/src/pages/ClientDetail.jsx | 2 +- frontend/src/pages/Journee.jsx | 271 ++++++++++++++++++++++++++++ 8 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 backend/src/routes/myday.js create mode 100644 backend/test/myday.test.js create mode 100644 frontend/src/pages/Journee.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 020b745..b99cec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Toutes les versions notables de TechIBase sont documentées ici. Le projet est e ## [Non publié] +- **« 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) - **La version 0.0.13 n'a pas été publiée** : la construction de l'image de l'interface s'est bloquée sous émulation ; seules trois de ses images existent sur le registre, sans étiquette ni release GitHub. Cette version 0.0.14 contient tout ce que devait contenir la 0.0.13 (mises à jour en un clic, ci-dessous). 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..c524490 --- /dev/null +++ b/backend/src/routes/myday.js @@ -0,0 +1,82 @@ +const express = require('express'); +const router = express.Router(); +const { authenticate } = require('../middleware/auth'); +const { validate } = require('../middleware/validate'); +const { serverError } = require('../lib/respond'); + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +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. +router.get('/work-orders/my-day', authenticate, async (req, res) => { + const db = req.app.locals.db; + const { date } = req.query; + if (date !== undefined && (typeof date !== 'string' || !DATE_RE.test(date) || Number.isNaN(Date.parse(date)))) { + return res.status(400).json({ error: 'Date invalide (AAAA-MM-JJ attendu)' }); + } + try { + const day = date || (await db.query("SELECT to_char(CURRENT_DATE, 'YYYY-MM-DD') AS d")).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 >= $2::date AND wo.completed_at < $2::date + 1) + ) + 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] + ); + 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); + } +}); + +// 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; + try { + const found = await db.query('SELECT 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é' }); + 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, req.params.id] + ); + res.json(result.rows[0]); + } catch (err) { + serverError(res, err); + } +}); + +module.exports = router; diff --git a/backend/test/myday.test.js b/backend/test/myday.test.js new file mode 100644 index 0000000..f5f29d2 --- /dev/null +++ b/backend/test/myday.test.js @@ -0,0 +1,93 @@ +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); +}); 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 4c8ec5f..45ca5b1 100644 --- a/frontend/src/pages/ClientDetail.jsx +++ b/frontend/src/pages/ClientDetail.jsx @@ -894,7 +894,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'); diff --git a/frontend/src/pages/Journee.jsx b/frontend/src/pages/Journee.jsx new file mode 100644 index 0000000..9066b81 --- /dev/null +++ b/frontend/src/pages/Journee.jsx @@ -0,0 +1,271 @@ +import React, { useEffect, useState, useCallback } 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)}`; +const telHref = (phone) => `tel:${phone.replace(/[^\d+]/g, '')}`; + +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 && ( + + + {wo.client_phone} + + )} +
    + )} + + {noteOpen && ( +
    + +