) =>
+ setLignes((l) => l.map((ligne, i) => (i === index ? { ...ligne, ...patch } : ligne)));
+
+ const total = lignes.reduce((somme, l) => somme + l.quantity * l.unitPrice, 0);
+ const valide =
+ supplierId && lignes.length > 0 && lignes.every((l) => l.partId && l.quantity > 0 && l.unitPrice > 0);
+
+ return (
+ <>
+
+ Bons de commande / nouveau
+
+
+
Nouveau bon de commande
+
+
+
+
+
+
+
+
+
+
+
+ Total
+ {fmt.format(total)} MAD
+
+ {creation.isError ?
{creation.error.message}
: null}
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/pages/personnes.tsx b/apps/web/src/pages/personnes.tsx
index d76d223..e45f7ac 100644
--- a/apps/web/src/pages/personnes.tsx
+++ b/apps/web/src/pages/personnes.tsx
@@ -6,6 +6,7 @@ import {
useResendInvitation,
useRoles,
useTeams,
+ useUpdateUser,
useUsers,
} from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
@@ -44,6 +45,7 @@ export default function PagePersonnes() {
Personne |
Rôle |
Équipe |
+ Taux horaire |
Statut |
|
@@ -66,6 +68,13 @@ export default function PagePersonnes() {
{u.role.name} |
{u.teams.map((t) => t.name).join(', ') || '—'} |
+
+
+ |
{u.status === 'active' ? (
Actif
@@ -139,6 +148,64 @@ export default function PagePersonnes() {
);
}
+/** Taux horaire courant (R3) — appliqué aux futures saisies de temps ; les
+ * temps déjà valorisés gardent leur taux figé. */
+function CelluleTaux({
+ userId,
+ taux,
+ editable,
+}: {
+ userId: string;
+ taux: number | null;
+ editable: boolean;
+}) {
+ const maj = useUpdateUser();
+ const [edition, setEdition] = useState(false);
+
+ if (!edition) {
+ return (
+
+ {taux != null ? `${new Intl.NumberFormat('fr-FR').format(taux)} MAD/h` : '—'}
+ {editable ? (
+
+ ) : null}
+
+ );
+ }
+ return (
+ {
+ const valeur = e.target.value.trim();
+ const nombre = Number(valeur);
+ if (valeur && nombre > 0 && nombre !== taux) {
+ maj.mutate({ id: userId, hourlyRate: nombre }, { onSettled: () => setEdition(false) });
+ } else {
+ setEdition(false);
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') e.currentTarget.blur();
+ if (e.key === 'Escape') setEdition(false);
+ }}
+ />
+ );
+}
+
function ModaleInvitation({
ouverte,
surFermeture,
diff --git a/apps/web/src/pages/statistiques.tsx b/apps/web/src/pages/statistiques.tsx
new file mode 100644
index 0000000..e9da204
--- /dev/null
+++ b/apps/web/src/pages/statistiques.tsx
@@ -0,0 +1,145 @@
+import { useAnalyticsSummary } from '@/api/gestion';
+
+const fmt = new Intl.NumberFormat('fr-FR');
+const fmtCompact = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 1 });
+
+function enMilliers(v: number): string {
+ return v >= 1000 ? `${fmtCompact.format(v / 1000)} k` : fmt.format(v);
+}
+
+/** « 2026-03 » → « mars » (maquette : étiquettes de mois en toutes lettres). */
+function moisLisible(period: string): string {
+ const [annee, mois] = period.split('-').map(Number);
+ return new Intl.DateTimeFormat('fr-FR', { month: 'long' }).format(new Date(annee, mois - 1, 1));
+}
+
+/** Écran 5 des maquettes R3 : M. Bennis lit en 30 s — barres horizontales
+ * à étiquettes directes (palette CVD validée), chiffres tabulaires. */
+export default function PageStatistiques() {
+ const { data } = useAnalyticsSummary();
+ if (!data) return null;
+
+ const delta =
+ data.previousMonthCost > 0
+ ? Math.round(((data.monthCost - data.previousMonthCost) / data.previousMonthCost) * 100)
+ : null;
+ const maxPannes = Math.max(...data.failuresByComponent.map((f) => f.count), 1);
+ const maxCout = Math.max(...data.costsByMonth.map((c) => c.total), 1);
+
+ return (
+ <>
+
+ Statistiques
+ 12 derniers mois
+
+
+
+
+ Coût · {new Intl.DateTimeFormat('fr-FR', { month: 'long' }).format(new Date())}
+
+
+ {fmt.format(data.monthCost)}{' '}
+ MAD
+
+ {delta != null ? (
+
+ {delta > 0 ? '+' : '−'}{Math.abs(delta)} % vs mois précédent
+
+ ) : (
+ pas de mois précédent
+ )}
+
+
+ OT clôturés · 12 mois
+ {fmt.format(data.closed12m.total)}
+ dont {fmt.format(data.closed12m.preventive)} préventifs
+
+
+ Taux de préventif
+
+ {data.preventiveRate != null ? `${Math.round(data.preventiveRate * 100)} %` : '—'}
+
+ grilles faites / dues
+
+
+ Durée moyenne de résolution
+
+ {data.avgResolutionDays != null ? `${fmtCompact.format(data.avgResolutionDays)} j` : '—'}
+
+ dépannages, 12 mois
+
+
+
+
+
+ Pannes par organe · 12 mois{' '}
+
+ (bilans codés)
+
+
+
+ {data.failuresByComponent.map((f) => (
+
+ {f.label}
+
+ {f.count}
+
+ ))}
+ {!data.failuresByComponent.length ? (
+ Aucun bilan codé sur la période.
+ ) : null}
+
+
+
+
+ Coûts par mois{' '}
+
+ (pièces + main-d'œuvre, MAD)
+
+
+
+ {data.costsByMonth.map((c) => (
+
+ {moisLisible(c.month)}
+
+ {enMilliers(c.total)}
+
+ ))}
+
+
+
+
+ Top équipements en coût · 12 mois
+
+
+
+ | Appareil | Site | Dépannages | Pièces | Main-d'œuvre | Total |
+
+
+ {data.topAssets.map((a) => (
+
+ | {a.reference} |
+ {a.siteName} |
+ {a.correctives} |
+ {fmt.format(a.partsCost)} |
+ {fmt.format(a.laborCost)} |
+ {fmt.format(a.total)} MAD |
+
+ ))}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/pages/stock.tsx b/apps/web/src/pages/stock.tsx
new file mode 100644
index 0000000..0e2eb33
--- /dev/null
+++ b/apps/web/src/pages/stock.tsx
@@ -0,0 +1,183 @@
+import { useState, type FormEvent } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useCreatePart, usePartners, useParts } from '@/api/gestion';
+import { usePermissions } from '@/auth/use-permissions';
+import { Button } from '@/components/ui/button';
+import { Modale } from '@/components/ui/modale';
+
+const fmt = new Intl.NumberFormat('fr-FR');
+
+/** Écran 1 des maquettes R3 : le stock DÉRIVÉ des mouvements — jamais de
+ * quantité saisie. Sous le seuil : strie ambre + « Préparer le BC ». */
+export default function PageStock() {
+ const navigate = useNavigate();
+ const { data: parts } = useParts();
+ const { can } = usePermissions();
+ const [modale, setModale] = useState(false);
+ const [alerte, setAlerte] = useState(false);
+
+ const toutes = parts ?? [];
+ const sousSeuil = toutes.filter((p) => p.belowThreshold && p.isActive);
+ const visibles = alerte ? sousSeuil : toutes;
+
+ return (
+ <>
+
+ Stock de pièces
+
+ {toutes.length} références
+ {sousSeuil.length ? (
+ <>
+ {' · '}
+ {sousSeuil.length} sous le seuil
+ >
+ ) : null}
+
+
+ {can('PURCHASE_ORDERS', 'view') ? (
+
+ ) : null}
+ {can('PARTS', 'create') ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ | Réf. |
+ Désignation |
+ Stock |
+ Seuil |
+ Dernier PU |
+ Fournisseur |
+ |
+
+
+
+ {visibles.map((p) => (
+ navigate(`/stock/${p.id}`)}
+ style={{ cursor: 'pointer', color: !p.isActive ? 'var(--encre-3)' : undefined }}
+ >
+ | {p.reference} |
+ {p.designation}{!p.isActive ? ' (désactivée)' : ''} |
+ {p.belowThreshold ? {p.stock} : p.stock} |
+ {p.threshold} |
+ {p.lastUnitPrice != null ? `${fmt.format(p.lastUnitPrice)} MAD` : '—'} |
+ {p.supplierName ?? '—'} |
+ e.stopPropagation()}>
+ {p.belowThreshold && can('PURCHASE_ORDERS', 'create') ? (
+ <>
+ Sous le seuil{' '}
+
+ Préparer le BC
+
+ >
+ ) : null}
+ |
+
+ ))}
+
+
+
+
+
+ Le stock affiché = somme des mouvements (réceptions de BC, entrées manuelles,
+ consommations d'OT, ajustements motivés). Aucune saisie directe de quantité.
+
+ {can('PARTS', 'create') ? : null}
+ >
+ );
+}
+
+function NouvellePiece({
+ ouverte,
+ surFermeture,
+}: {
+ ouverte: boolean;
+ surFermeture: (o: boolean) => void;
+}) {
+ const navigate = useNavigate();
+ const { data: partners } = usePartners();
+ const creation = useCreatePart();
+ const fournisseurs = (partners ?? []).filter((p) => p.kind === 'SUPPLIER' && p.isActive);
+
+ const surEnvoi = (e: FormEvent) => {
+ e.preventDefault();
+ const donnees = new FormData(e.currentTarget);
+ const champ = (n: string) => String(donnees.get(n) ?? '').trim();
+ creation.mutate(
+ {
+ designation: champ('designation'),
+ threshold: champ('threshold') ? Number(champ('threshold')) : undefined,
+ supplierId: champ('supplierId') || undefined,
+ compatible: champ('compatible') || undefined,
+ initialPrice: champ('initialPrice') ? Number(champ('initialPrice')) : undefined,
+ },
+ { onSuccess: (piece) => navigate(`/stock/${piece.id}`) },
+ );
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/tableau-de-bord.tsx b/apps/web/src/pages/tableau-de-bord.tsx
index 54c5f36..fa3a423 100644
--- a/apps/web/src/pages/tableau-de-bord.tsx
+++ b/apps/web/src/pages/tableau-de-bord.tsx
@@ -5,6 +5,7 @@ import {
type WorkOrderStatus,
} from '@siop/shared';
import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation';
+import { useAnalyticsSummary } from '@/api/gestion';
import { useMe } from '@/auth/use-auth';
import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar';
@@ -18,15 +19,16 @@ const ST_COULEURS: Record = {
DONE: 'var(--st-termine)',
};
-/** Écran 2 validé R0 — alimenté par les données réelles de R2.
- * (Coûts → R3 ; pannes par organe → analytics R3.) */
+/** Écran 2 validé R0 — données réelles R2, pannes par organe réelles R3. */
export default function PageTableauDeBord() {
const navigate = useNavigate();
const { can, chargé } = usePermissions();
const peutVoirOT = can('WORK_ORDERS', 'view');
+ const peutVoirStats = can('ANALYTICS', 'view');
const { data: workOrders } = useWorkOrders(peutVoirOT);
const { data: requests } = useRequests();
const { data: preventif } = usePreventiveStatus(peutVoirOT);
+ const { data: stats } = useAnalyticsSummary(peutVoirStats);
if (!chargé) return null;
if (!peutVoirOT) return ;
@@ -120,12 +122,47 @@ export default function PageTableauDeBord() {
))}
-
- Pannes par organe · 12 mois
-
- Ce graphique s'alimentera des bilans codés — il arrive avec les analytics (R3).
-
-
+ {peutVoirStats ? (
+
+
+ Pannes par organe · 12 mois{' '}
+
+ (bilans codés)
+
+
+ {stats?.failuresByComponent.length ? (
+
+ {stats.failuresByComponent.map((f) => (
+
+ {f.label}
+
+ x.count), 1)) * 100}%`,
+ background: 'var(--graf-1)',
+ }}
+ />
+
+ {f.count}
+
+ ))}
+
+ ) : (
+ Aucun bilan codé sur la période.
+ )}
+
+
+ Toutes les statistiques →
+
+
+
+ ) : (
+
+ Pannes par organe · 12 mois
+ Réservé aux rôles disposant du pilotage.
+
+ )}
Interventions récentes
diff --git a/apps/web/src/pages/tiers.tsx b/apps/web/src/pages/tiers.tsx
new file mode 100644
index 0000000..7fbd4b2
--- /dev/null
+++ b/apps/web/src/pages/tiers.tsx
@@ -0,0 +1,131 @@
+import { useState, type FormEvent } from 'react';
+import { PARTNER_KIND_LABELS, PARTNER_KINDS } from '@siop/shared';
+import { useCreatePartner, usePartners } from '@/api/gestion';
+import { usePermissions } from '@/auth/use-permissions';
+import { Button } from '@/components/ui/button';
+import { Modale } from '@/components/ui/modale';
+
+/** Écran 6 des maquettes R3 : annuaire minimal — pas un CRM. */
+export default function PageTiers() {
+ const { data: partners } = usePartners();
+ const { can } = usePermissions();
+ const [modale, setModale] = useState(false);
+
+ return (
+ <>
+
+ Tiers
+
+ {can('PURCHASE_ORDERS', 'create') ? (
+
+ ) : null}
+
+
+
+
+
+
+ | Nom | Type | Contact | Rattachements |
+
+
+ {(partners ?? []).map((p) => (
+
+ |
+ {p.name}
+ {p.city ? {p.city} : null}
+ |
+ {PARTNER_KIND_LABELS[p.kind]} |
+
+ {p.contactName}
+ {p.contactName && p.phone ? ' · ' : null}
+ {p.phone ? {p.phone} : null}
+ {!p.contactName && !p.phone && !p.email ? '—' : null}
+ {p.email ? {p.email} : null}
+ |
+
+ {p.kind === 'SUPPLIER'
+ ? p.openOrders
+ ? `${p.openOrders} BC en cours`
+ : '—'
+ : '—'}
+ |
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
+
+function NouveauTiers({
+ ouverte,
+ surFermeture,
+}: {
+ ouverte: boolean;
+ surFermeture: (o: boolean) => void;
+}) {
+ const creation = useCreatePartner();
+ const surEnvoi = (e: FormEvent ) => {
+ e.preventDefault();
+ const donnees = new FormData(e.currentTarget);
+ const champ = (n: string) => String(donnees.get(n) ?? '').trim();
+ creation.mutate(
+ {
+ name: champ('name'),
+ kind: champ('kind') as 'SUPPLIER' | 'CLIENT',
+ contactName: champ('contactName') || undefined,
+ phone: champ('phone') || undefined,
+ email: champ('email') || undefined,
+ city: champ('city') || undefined,
+ },
+ { onSuccess: () => surFermeture(false) },
+ );
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css
index 934a55c..531f4a2 100644
--- a/apps/web/src/styles/app.css
+++ b/apps/web/src/styles/app.css
@@ -873,6 +873,50 @@ table {
color: var(--encre); text-align: right;
}
+/* ═══ R3 — Gestion (classes extraites de maquette-r3.html validée) ═══ */
+/* Statuts de BON DE COMMANDE */
+.st.brouillon { color: var(--st-annule); background: var(--st-annule-fond); }
+.st.envoye { color: var(--st-ouvert); background: var(--st-ouvert-fond); }
+.st.recu { color: var(--st-termine); background: var(--st-termine-fond); }
+
+/* Alerte de stock sous seuil */
+.stock-bas {
+ display: inline-flex; align-items: center; gap: 6px;
+ color: var(--alerte); font-weight: 700; font-size: 12.5px;
+}
+.stock-bas::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--alerte); }
+.ligne-seuil td:first-child { box-shadow: inset 3px 0 0 var(--alerte); }
+
+/* Mouvements de stock */
+.mvt { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; font-size: 12.5px; }
+.mvt.entree { color: var(--succes); }
+.mvt.sortie { color: var(--danger); }
+
+/* Coûts */
+.total { display: flex; justify-content: space-between; font-weight: 700; padding-top: 10px; font-size: 14px; }
+
+/* Bibliothèque */
+.docs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
+@media (max-width: 1100px) { .docs { grid-template-columns: repeat(2, 1fr); } }
+.doc {
+ border: 1px solid var(--bordure); border-radius: var(--rayon); background: var(--surface);
+ padding: 12px; display: flex; flex-direction: column; gap: 8px; font-size: 12.5px;
+}
+.doc .vignette {
+ height: 84px; border-radius: 8px; background: var(--surface-2);
+ display: flex; align-items: center; justify-content: center;
+ color: var(--encre-3); font-size: 24px;
+}
+.doc b { font-size: 13px; word-break: break-all; }
+.doc .meta { color: var(--encre-2); font-size: 11.5px; }
+.type-doc {
+ display: inline-flex; font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em;
+ text-transform: uppercase; color: var(--primaire); background: var(--primaire-doux);
+ padding: 2px 8px; border-radius: var(--rayon-pastille); align-self: flex-start;
+}
+.type-doc.certificat { color: var(--safran); background: var(--safran-doux); }
+.type-doc.photo { color: var(--graf-3); background: var(--st-termine-fond); }
+
/* ═══ Portail demandeur (écran 6 validé R0 — aucun compte, aucun jargon) ═══ */
.portail {
max-width: 430px;
diff --git a/docs/journal/journal.md b/docs/journal/journal.md
index 4f3a6b0..10271dc 100644
--- a/docs/journal/journal.md
+++ b/docs/journal/journal.md
@@ -4,6 +4,24 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
---
+## 2026-07-16 — Pr. Daaif (+ Claude) — R3.3 : les écrans web de la gestion — R3 prête pour recette
+
+**Actions**
+
+- **7 écrans fidèles à `maquette-r3.html`** : Stock (alerte sous seuil, strie ambre, « Préparer le BC » sur la ligne), Fiche pièce (« les mouvements SONT le stock », entrée/ajustement motivé), Bons de commande (liste + panneau, envoi/annulation/**réception → entrées de stock**), création de BC **préremplie** depuis l'alerte (fournisseur de la pièce, quantité pour repasser le seuil + 5, dernier PU), Tiers, Bibliothèque (upload rattaché à un appareil, filtre par type), Statistiques (4 KPI, pannes par organe **depuis les bilans codés**, coûts par mois, top équipements).
+- **La fiche OT s'achève** : carte « Pièces & main-d'œuvre » réelle (consommer une pièce — prix figé annoncé AVANT le clic, impact stock affiché ; saisir du temps au taux figé) + carte « Documents » réelle (photo d'intervention téléversée/retéléchargée). La fiche ascenseur gagne la même carte Documents ; le tableau de bord allume « Pannes par organe » ; **Personnes affiche et édite le taux horaire**.
+- Nav « Ressources »/« Statistiques » activée par la matrice (PARTS/PURCHASE_ORDERS/ASSETS/ANALYTICS.view) ; captures des 9 écrans comparées aux maquettes (bi-thème), zéro erreur console.
+- **Recette R3 automatisée** (2 parcours Playwright, données E2E autonomes purgées au setup) : tiers → pièce → sous seuil → BC prérempli → réception → **l'alerte s'éteint** ; OT : consommation + 1 h 30 → **380 MAD** exacts ; upload/download MinIO réel ; statistiques lues par la Vue seule. **13/13 e2e verts, 10,7 s.**
+
+**Décisions**
+
+- Le formulaire BC prérempli attend la fin du refetch avant d'initialiser ses lignes (le cache TanStack portait un stock périmé — attrapé par la recette, pas par l'œil).
+- `cleanup-e2e` purge désormais les entités R3 (mouvements liés aux OT E2E supprimés AVANT les OT, sinon ils deviennent orphelins et faussent les stocks seedés d'un run à l'autre).
+
+**Prochaine étape** : recette R3 avec le référent (revue pixel), déploiement Dokploy, tag `release/r3` → puis R4 Mobile (design d'abord).
+
+---
+
## 2026-07-16 — Pr. Daaif (+ Claude) — R3.2 : bibliothèque de documents + analytics
**Actions**
|