Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions backend/node_modules
2 changes: 2 additions & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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);
Expand Down
116 changes: 116 additions & 0 deletions backend/src/routes/myday.js
Original file line number Diff line number Diff line change
@@ -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;
147 changes: 147 additions & 0 deletions backend/test/myday.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
27 changes: 26 additions & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 ? <Navigate to="/journee" replace /> : <Home />;
}

export default function App() {
return (
Expand All @@ -23,7 +38,17 @@ export default function App() {
element={
<ProtectedRoute>
<Layout>
<Home />
<HomeOrDay />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/journee"
element={
<ProtectedRoute>
<Layout>
<Journee />
</Layout>
</ProtectedRoute>
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '📋' },
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/pages/ClientDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 },
Expand Down Expand Up @@ -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'
}`}
Expand Down
Loading
Loading