feat(r3.4): corrections de recette (8 écarts) + recherche globale ⌘K

Arbitrage du référent sur la revue pixel : tout corriger, activer la
recherche.

- Stock : filtre fournisseur, sous-seuil en tête, « Entrée de stock »
  depuis la liste ; fiche pièce : fournisseur → lien Tiers.
- Statistiques : période 3/6/12 mois (paramètre months au contrat).
- Tiers : rattachements syndic→site (migration r3_recette_fixes,
  Location.partnerId gardé CLIENT), éditable sur la fiche site, seedé.
- Bibliothèque : filtre « Rattaché à » + glisser-déposer (modale
  préremplie, rattachement toujours requis).
- Recherche globale : GET /search (73 opérations) — familles OT/
  ascenseurs/sites filtrées par la matrice, « voir autre » respecté ;
  topbar ⌘K, debounce, résultats groupés, navigation clavier.

74 tests API (8 nouveaux sur le scoping de la recherche), 14/14
Playwright dont un parcours « recette corrigée », 18/18 contrôles en
navigateur réel, zéro erreur console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-17 02:14:43 +01:00
parent c46a97ed08
commit 460ef4a80e
38 changed files with 1318 additions and 65 deletions

View File

@@ -48,7 +48,7 @@ test('recette R3 : tiers → pièce sous seuil → BC prérempli → réception
await page.getByLabel('Désignation *').fill(piece);
await page.getByLabel("Seuil d'alerte").fill('5');
await page.getByLabel('Prix initial (MAD)').fill('100');
await page.getByLabel('Fournisseur').selectOption({ label: fournisseur });
await page.getByLabel('Fournisseur', { exact: true }).selectOption({ label: fournisseur });
await page.getByRole('button', { name: 'Créer la pièce' }).click();
await expect(page.getByRole('heading', { name: piece })).toBeVisible();
@@ -157,3 +157,53 @@ test('recette R3 : coûts réels sur OT (prix + taux figés) + document + statis
await expect(page.getByText('Pannes par organe · 12 mois')).toBeVisible();
await expect(page.getByText('Top équipements en coût · 12 mois')).toBeVisible();
});
test('recette R3 corrigée : recherche ⌘K, tri/filtre stock, période stats, rattachements, dépôt', async ({
page,
}) => {
await connexionDemo(page, /Yasmine Alaoui/);
// Recherche globale : ⌘K → site (insensible à la casse), clic → fiche site
await page.keyboard.press('ControlOrMeta+k');
await page.keyboard.type('atlas');
await page.locator('.resultat', { hasText: 'Tour Atlas' }).first().click();
await expect(page).toHaveURL(/\/sites\//);
// le site porte son syndic (rattachement seedé, éditable par l'admin)
await expect(page.getByLabel('Client / syndic du site')).toHaveValue(/.+/);
// Recherche OT au clavier seul (Entrée sur le premier résultat)
await page.keyboard.press('ControlOrMeta+k');
await page.keyboard.type('bruit');
await page.locator('.resultat', { hasText: 'Bruit anormal' }).first().waitFor();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/\/ot\//);
// Stock : les pièces sous le seuil remontent en tête, filtre fournisseur,
// « Entrée de stock » accessible depuis la liste (choix de la pièce)
await page.getByRole('link', { name: 'Stock & achats' }).click();
await expect(page.locator('tbody tr').first().locator('.stock-bas')).toHaveText(/Sous le seuil/);
await page.getByLabel('Filtrer par fournisseur').selectOption({ label: 'Lubmaroc' });
await expect(page.locator('tbody tr', { hasText: 'P-0042' })).toHaveCount(1);
await page.getByLabel('Filtrer par fournisseur').selectOption({ index: 0 });
await page.getByRole('button', { name: 'Entrée de stock' }).click();
await expect(page.getByLabel('Pièce *')).toBeVisible();
await page.getByRole('button', { name: 'Annuler' }).click();
// Fiche pièce : le fournisseur est un lien vers les tiers
await page.getByRole('cell', { name: 'P-0113' }).click();
await page.getByRole('link', { name: 'Ascentech Maroc' }).click();
await expect(page).toHaveURL(/\/tiers/);
// Tiers : les syndics affichent leurs sites
await expect(page.getByRole('cell', { name: 'Tour Atlas' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Résidence Al Manar' })).toBeVisible();
// Statistiques : la période est un vrai sélecteur
await page.getByRole('link', { name: 'Statistiques', exact: true }).click();
await page.getByLabel('Période').selectOption('3');
await expect(page.getByText('OT clôturés · 3 mois')).toBeVisible();
// Bibliothèque : filtre « Rattaché à » + zone de glisser-déposer
await page.getByRole('link', { name: 'Fichiers' }).click();
await expect(page.getByLabel('Filtrer par appareil')).toBeVisible();
await expect(page.locator('.zone-depot')).toBeVisible();
});

View File

@@ -254,10 +254,15 @@ export async function ouvrirDocument(id: string): Promise<void> {
// ————— Analytics —————
export function useAnalyticsSummary(enabled = true) {
export function useAnalyticsSummary(months: 3 | 6 | 12 = 12, enabled = true) {
return useQuery({
queryKey: ['analytics'],
queryKey: ['analytics', months],
enabled,
queryFn: async () => unwrap(await api.GET('/analytics/summary')),
queryFn: async () =>
unwrap(
await api.GET('/analytics/summary', {
params: { query: { months: String(months) } },
}),
),
});
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { SEARCH_MIN_CHARS } from '@siop/shared';
import { api } from './client';
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.data !== undefined) return res.data;
throw new Error(`Recherche indisponible (${res.response.status})`);
}
/** Recherche globale de la topbar (⌘K). L'API filtre chaque famille par la
* matrice — le client n'a aucune logique de permission à porter. */
export function useGlobalSearch(q: string) {
const terme = q.trim();
return useQuery({
queryKey: ['search', terme],
enabled: terme.length >= SEARCH_MIN_CHARS,
staleTime: 30_000,
placeholderData: (precedent) => precedent, // pas de clignotement en tapant
queryFn: async () =>
unwrap(await api.GET('/search', { params: { query: { q: terme } } })),
});
}

View File

@@ -7,6 +7,7 @@ import type {
CategoryUpdate,
InvitationCreate,
LocationCreate,
LocationUpdate,
TeamCreate,
UserUpdate,
} from '@siop/shared';
@@ -98,6 +99,15 @@ export function useCreateLocation() {
});
}
export function useUpdateLocation(id: string) {
const invalidate = useInvalidate('locations', 'partners');
return useMutation({
mutationFn: async (body: LocationUpdate) =>
unwrap(await api.PATCH('/locations/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useCreateAsset() {
const invalidate = useInvalidate('assets', 'locations');
return useMutation({

View File

@@ -441,6 +441,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/search": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Recherche globale (OT, ascenseurs, sites) — chaque famille filtrée par la matrice */
get: operations["globalSearch"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/partners": {
parameters: {
query?: never;
@@ -1101,6 +1118,8 @@ export interface components {
guardianPhone: string | null;
latitude: number | null;
longitude: number | null;
partnerId: string | null;
partnerName: string | null;
assetCount: number;
}[];
};
@@ -1115,6 +1134,8 @@ export interface components {
guardianPhone: string | null;
latitude: number | null;
longitude: number | null;
partnerId: string | null;
partnerName: string | null;
assetCount: number;
};
LocationCreate: {
@@ -1127,6 +1148,7 @@ export interface components {
guardianPhone?: string;
latitude?: number;
longitude?: number;
partnerId?: string | null;
};
LocationUpdate: {
name?: string;
@@ -1138,6 +1160,7 @@ export interface components {
guardianPhone?: string;
latitude?: number;
longitude?: number;
partnerId?: string | null;
};
AssetsResponse: {
assets: {
@@ -1397,9 +1420,10 @@ export interface components {
createdAt: string;
};
AnalyticsSummary: {
months: number;
monthCost: number;
previousMonthCost: number;
closed12m: {
closed: {
total: number;
preventive: number;
};
@@ -1422,6 +1446,30 @@ export interface components {
total: number;
}[];
};
SearchResponse: {
workOrders: {
/** Format: uuid */
id: string;
reference: string;
title: string;
/** @enum {string} */
status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED";
}[];
assets: {
/** Format: uuid */
id: string;
reference: string;
brand: string;
model: string | null;
siteName: string;
}[];
sites: {
/** Format: uuid */
id: string;
name: string;
city: string | null;
}[];
};
PartnersResponse: {
partners: {
/** Format: uuid */
@@ -1435,6 +1483,7 @@ export interface components {
city: string | null;
isActive: boolean;
openOrders: number;
siteNames: string[];
}[];
};
Partner: {
@@ -1449,6 +1498,7 @@ export interface components {
city: string | null;
isActive: boolean;
openOrders: number;
siteNames: string[];
};
PartnerCreate: {
name: string;
@@ -2889,7 +2939,9 @@ export interface operations {
};
getAnalyticsSummary: {
parameters: {
query?: never;
query?: {
months?: string;
};
header?: never;
path?: never;
cookie?: never;
@@ -2907,6 +2959,28 @@ export interface operations {
};
};
};
globalSearch: {
parameters: {
query: {
q: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Résultats (5 max par famille) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SearchResponse"];
};
};
};
};
listPartners: {
parameters: {
query?: never;

View File

@@ -0,0 +1,179 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { SEARCH_MIN_CHARS } from '@siop/shared';
import { useGlobalSearch } from '@/api/recherche';
import { StatutOT } from '@/components/chips-ot';
import { IcoRecherche } from '@/components/nav-icons';
interface Resultat {
cle: string;
route: string;
contenu: React.ReactNode;
}
/** La recherche de la topbar, enfin active (recette R3) : ⌘K pour saisir,
* résultats groupés (OT / ascenseurs / sites), déjà filtrés par la matrice
* côté API — flèches + Entrée pour naviguer. */
export function RechercheGlobale() {
const navigate = useNavigate();
const [saisie, setSaisie] = useState('');
const [q, setQ] = useState('');
const [ouverte, setOuverte] = useState(false);
const [actif, setActif] = useState(0);
const champ = useRef<HTMLInputElement>(null);
// Débounce : on n'interroge l'API que 200 ms après la dernière frappe.
useEffect(() => {
const t = setTimeout(() => setQ(saisie), 200);
return () => clearTimeout(t);
}, [saisie]);
// ⌘K / Ctrl+K : focus depuis n'importe où.
useEffect(() => {
const surTouche = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
champ.current?.focus();
champ.current?.select();
}
};
window.addEventListener('keydown', surTouche);
return () => window.removeEventListener('keydown', surTouche);
}, []);
const { data } = useGlobalSearch(q);
const resultats = useMemo<Resultat[]>(() => {
if (!data) return [];
return [
...data.workOrders.map((w) => ({
cle: `ot-${w.id}`,
route: `/ot/${w.id}`,
contenu: (
<>
<span className="ref num">{w.reference}</span>
<span className="titre">{w.title}</span>
<span className="fin"><StatutOT statut={w.status} /></span>
</>
),
})),
...data.assets.map((a) => ({
cle: `asc-${a.id}`,
route: `/ascenseurs/${a.id}`,
contenu: (
<>
<span className="ref num">{a.reference}</span>
<span className="titre">
{a.brand}
{a.model ? ` ${a.model}` : ''}
</span>
<span className="fin">{a.siteName}</span>
</>
),
})),
...data.sites.map((s) => ({
cle: `site-${s.id}`,
route: `/sites/${s.id}`,
contenu: (
<>
<span className="titre"><b>{s.name}</b></span>
<span className="fin">{s.city ?? ''}</span>
</>
),
})),
];
}, [data]);
const groupes = useMemo(() => {
if (!data) return [];
const g: { libelle: string; debut: number; nombre: number }[] = [];
if (data.workOrders.length) g.push({ libelle: 'Ordres de travail', debut: 0, nombre: data.workOrders.length });
if (data.assets.length) g.push({ libelle: 'Ascenseurs', debut: data.workOrders.length, nombre: data.assets.length });
if (data.sites.length) {
g.push({
libelle: 'Sites',
debut: data.workOrders.length + data.assets.length,
nombre: data.sites.length,
});
}
return g;
}, [data]);
const visible = ouverte && q.trim().length >= SEARCH_MIN_CHARS;
const aller = (r: Resultat | undefined) => {
if (!r) return;
setOuverte(false);
setSaisie('');
navigate(r.route);
};
return (
<div className="recherche" data-active>
<IcoRecherche />
<input
ref={champ}
type="search"
placeholder="Rechercher un OT, un ascenseur, un site…"
aria-label="Recherche globale"
value={saisie}
onChange={(e) => {
setSaisie(e.target.value);
setOuverte(true);
setActif(0);
}}
onFocus={() => setOuverte(true)}
onBlur={() => setOuverte(false)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOuverte(false);
champ.current?.blur();
}
if (!visible || !resultats.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActif((a) => Math.min(a + 1, resultats.length - 1));
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActif((a) => Math.max(a - 1, 0));
}
if (e.key === 'Enter') {
e.preventDefault();
aller(resultats[actif]);
}
}}
/>
<kbd>K</kbd>
{visible ? (
<div className="resultats" role="listbox" aria-label="Résultats">
{groupes.map((g) => (
<div key={g.libelle}>
<div className="groupe-resultats">{g.libelle}</div>
{resultats.slice(g.debut, g.debut + g.nombre).map((r, i) => (
<button
key={r.cle}
type="button"
role="option"
aria-selected={g.debut + i === actif}
className="resultat"
data-actif={g.debut + i === actif || undefined}
onMouseEnter={() => setActif(g.debut + i)}
onMouseDown={(e) => {
e.preventDefault(); // avant le blur du champ
aller(r);
}}
>
{r.contenu}
</button>
))}
</div>
))}
{!resultats.length ? (
<div className="groupe-resultats">Aucun résultat pour « {q.trim()} »</div>
) : null}
</div>
) : null}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { getToken } from '@/api/client';
import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth';
import { Avatar, initialsOf } from '@/components/avatar';
import { Logo } from '@/components/logo';
import { RechercheGlobale } from '@/components/recherche-globale';
import { useQuery } from '@tanstack/react-query';
import type { ObjectCategory, PermissionRight } from '@siop/shared';
import { api } from '@/api/client';
@@ -18,7 +19,6 @@ import {
IcoOrdresDeTravail,
IcoPersonnes,
IcoPreventif,
IcoRecherche,
IcoSites,
IcoStatistiques,
IcoStock,
@@ -185,10 +185,7 @@ export function Coquille({ children }: { children: ReactNode }) {
<div className="contenu">
<header className="topbar">
<div className="recherche" aria-hidden="true">
<IcoRecherche />
Rechercher un OT, un ascenseur, un site&ensp;K
</div>
<RechercheGlobale />
{urgences.length > 0 ? (
<button
type="button"

View File

@@ -10,6 +10,8 @@ const loc = (over: Partial<LocationDto>): LocationDto => ({
city: null,
guardianName: null,
guardianPhone: null,
partnerId: null,
partnerName: null,
latitude: null,
longitude: null,
assetCount: 0,

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState, type DragEvent } from 'react';
import {
DOCUMENT_KIND_LABELS,
DOCUMENT_KINDS,
@@ -17,13 +17,29 @@ import { Modale } from '@/components/ui/modale';
export default function PageBibliotheque() {
const { can } = usePermissions();
const [kind, setKind] = useState<DocumentKind | ''>('');
const { data: documents } = useDocuments(kind ? { kind } : {});
const [assetId, setAssetId] = useState('');
const { data: options } = useAssetOptions();
const { data: documents } = useDocuments({
...(kind ? { kind } : {}),
...(assetId ? { assetId } : {}),
});
const suppression = useDeleteDocument();
const [modale, setModale] = useState(false);
const [fichierDepose, setFichierDepose] = useState<File | null>(null);
const [survol, setSurvol] = useState(false);
const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0);
const peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit');
const surDepot = (e: DragEvent) => {
e.preventDefault();
setSurvol(false);
const fichier = e.dataTransfer.files?.[0];
if (!fichier) return;
setFichierDepose(fichier);
setModale(true);
};
return (
<>
<div className="entete-page">
@@ -48,7 +64,35 @@ export default function PageBibliotheque() {
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
))}
</select>
<select
aria-label="Filtrer par appareil"
value={assetId}
onChange={(e) => setAssetId(e.target.value)}
>
<option value="">Rattaché à : tout le parc</option>
{(options ?? []).map((a) => (
<option key={a.id} value={a.id}>
{a.reference} {a.siteName}
</option>
))}
</select>
</div>
{peutEditer ? (
<button
type="button"
className="zone-depot"
data-survol={survol || undefined}
onClick={() => setModale(true)}
onDragOver={(e) => {
e.preventDefault();
setSurvol(true);
}}
onDragLeave={() => setSurvol(false)}
onDrop={surDepot}
>
📂 Glissez un fichier ici PDF, JPG, PNG · 20 Mo max · rattachez-le à un appareil ou un OT
</button>
) : null}
{documents?.length ? (
<div className="docs">
{documents.map((d) => (
@@ -66,16 +110,27 @@ export default function PageBibliotheque() {
Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) —
le rattachement propre commence ici.
</div>
{peutEditer ? <ModaleTeleversement ouverte={modale} surFermeture={setModale} /> : null}
{peutEditer ? (
<ModaleTeleversement
ouverte={modale}
fichierInitial={fichierDepose}
surFermeture={(o) => {
setModale(o);
if (!o) setFichierDepose(null);
}}
/>
) : null}
</>
);
}
function ModaleTeleversement({
ouverte,
fichierInitial,
surFermeture,
}: {
ouverte: boolean;
fichierInitial?: File | null;
surFermeture: (o: boolean) => void;
}) {
const { data: options } = useAssetOptions();
@@ -85,6 +140,11 @@ function ModaleTeleversement({
const [assetId, setAssetId] = useState('');
const [fichier, setFichier] = useState<File | null>(null);
// Fichier arrivé par glisser-déposer : la modale s'ouvre déjà remplie.
useEffect(() => {
if (fichierInitial) setFichier(fichierInitial);
}, [fichierInitial]);
const envoyer = () => {
if (!fichier || !assetId) return;
if (fichier.size > DOCUMENT_MAX_BYTES) {

View File

@@ -52,7 +52,15 @@ export default function PageFichePiece() {
{piece.lastUnitPrice != null ? `${fmt.format(piece.lastUnitPrice)} MAD` : 'aucun prix connu'}
</dd>
<dt>Fournisseur</dt>
<dd>{piece.supplierName ?? ''}</dd>
<dd>
{piece.supplierName ? (
<Link to="/tiers" style={{ color: 'var(--primaire)', fontWeight: 600 }}>
{piece.supplierName}
</Link>
) : (
''
)}
</dd>
<dt>Compatible</dt>
<dd>{piece.compatible ?? ''}</dd>
</dl>

View File

@@ -1,6 +1,7 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useAssets, useCreateLocation, useLocations } from '@/api/referentiel';
import { usePartners } from '@/api/gestion';
import { useAssets, useCreateLocation, useLocations, useUpdateLocation } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { CarteGeo } from '@/components/carte-geo';
import { StatutEquipement } from '@/components/statut-equipement';
@@ -56,6 +57,14 @@ export default function PageFicheSite() {
<span className="num"> · {site.guardianPhone}</span>
) : null}
</dd>
<dt>Client / syndic</dt>
<dd>
<ChampSyndic
siteId={site.id}
partnerId={site.partnerId}
partnerName={site.partnerName}
/>
</dd>
<dt>Appareils</dt>
<dd className="num">{site.assetCount}</dd>
</dl>
@@ -121,6 +130,40 @@ export default function PageFicheSite() {
);
}
/** Rattachement du site à son client/syndic (colonne « Rattachements » des
* Tiers). Éditable si LOCATIONS.edit ET si la matrice donne accès aux tiers
* (la liste des tiers est sous PURCHASE_ORDERS.view) — sinon, lecture seule. */
function ChampSyndic({
siteId,
partnerId,
partnerName,
}: {
siteId: string;
partnerId: string | null;
partnerName: string | null;
}) {
const { can } = usePermissions();
const editable = can('LOCATIONS', 'edit') && can('PURCHASE_ORDERS', 'view');
const { data: partners } = usePartners(editable);
const maj = useUpdateLocation(siteId);
if (!editable) return <>{partnerName ?? '—'}</>;
const clients = (partners ?? []).filter((p) => p.kind === 'CLIENT' && p.isActive);
return (
<select
aria-label="Client / syndic du site"
value={partnerId ?? ''}
onChange={(e) => maj.mutate({ partnerId: e.target.value || null })}
disabled={maj.isPending}
>
<option value=""></option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
);
}
function NouvelleZone({
siteId,
ouverte,

View File

@@ -1,3 +1,5 @@
import { useState } from 'react';
import { ANALYTICS_PERIODS, type AnalyticsPeriod } from '@siop/shared';
import { useAnalyticsSummary } from '@/api/gestion';
const fmt = new Intl.NumberFormat('fr-FR');
@@ -16,7 +18,8 @@ function moisLisible(period: string): string {
/** É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();
const [periode, setPeriode] = useState<AnalyticsPeriod>(12);
const { data } = useAnalyticsSummary(periode);
if (!data) return null;
const delta =
@@ -30,7 +33,17 @@ export default function PageStatistiques() {
<>
<div className="entete-page">
<h1>Statistiques</h1>
<span className="filajout">12 derniers mois</span>
<div className="actions">
<select
aria-label="Période"
value={periode}
onChange={(e) => setPeriode(Number(e.target.value) as AnalyticsPeriod)}
>
{ANALYTICS_PERIODS.map((m) => (
<option key={m} value={m}>Période : {m} derniers mois</option>
))}
</select>
</div>
</div>
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
<div className="kpi">
@@ -50,9 +63,9 @@ export default function PageStatistiques() {
)}
</div>
<div className="kpi">
<div className="lib">OT clôturés · 12 mois</div>
<div className="val num">{fmt.format(data.closed12m.total)}</div>
<div className="delta neutre">dont {fmt.format(data.closed12m.preventive)} préventifs</div>
<div className="lib">OT clôturés · {data.months} mois</div>
<div className="val num">{fmt.format(data.closed.total)}</div>
<div className="delta neutre">dont {fmt.format(data.closed.preventive)} préventifs</div>
</div>
<div className="kpi">
<div className="lib">Taux de préventif</div>
@@ -66,13 +79,13 @@ export default function PageStatistiques() {
<div className="val num">
{data.avgResolutionDays != null ? `${fmtCompact.format(data.avgResolutionDays)} j` : '—'}
</div>
<div className="delta neutre">dépannages, 12 mois</div>
<div className="delta neutre">dépannages, {data.months} mois</div>
</div>
</div>
<div className="grille-2">
<div className="carte graf">
<h2>
Pannes par organe · 12 mois{' '}
Pannes par organe · {data.months} mois{' '}
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
(bilans codés)
</span>
@@ -119,7 +132,7 @@ export default function PageStatistiques() {
</div>
</div>
<div className="carte">
<h2>Top équipements en coût · 12 mois</h2>
<h2>Top équipements en coût · {data.months} mois</h2>
<div className="table">
<table>
<thead>

View File

@@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useCreatePart, usePartners, useParts } from '@/api/gestion';
import { useAddStockMovement, useCreatePart, usePartners, useParts } from '@/api/gestion';
import { usePermissions } from '@/auth/use-permissions';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
@@ -8,17 +8,26 @@ 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 ». */
* quantité saisie. Sous le seuil : strie ambre + « Préparer le BC »,
* et les pièces en alerte remontent en tête de liste (maquette). */
export default function PageStock() {
const navigate = useNavigate();
const { data: parts } = useParts();
const { can } = usePermissions();
const [modale, setModale] = useState(false);
const [modale, setModale] = useState<'piece' | 'entree' | null>(null);
const [alerte, setAlerte] = useState(false);
const [fournisseur, setFournisseur] = useState('');
const toutes = parts ?? [];
const sousSeuil = toutes.filter((p) => p.belowThreshold && p.isActive);
const visibles = alerte ? sousSeuil : toutes;
const fournisseurs = [...new Set(toutes.map((p) => p.supplierName).filter(Boolean))] as string[];
const visibles = (alerte ? sousSeuil : toutes)
.filter((p) => !fournisseur || p.supplierName === fournisseur)
.sort(
(a, b) =>
Number(b.belowThreshold && b.isActive) - Number(a.belowThreshold && a.isActive) ||
a.reference.localeCompare(b.reference),
);
return (
<>
@@ -37,8 +46,11 @@ export default function PageStock() {
{can('PURCHASE_ORDERS', 'view') ? (
<Link to="/achats"><Button>Bons de commande</Button></Link>
) : null}
{can('PARTS', 'edit') ? (
<Button onClick={() => setModale('entree')}>Entrée de stock</Button>
) : null}
{can('PARTS', 'create') ? (
<Button variant="prim" onClick={() => setModale(true)}>
<Button variant="prim" onClick={() => setModale('piece')}>
+ Nouvelle pièce
</Button>
) : null}
@@ -53,6 +65,16 @@ export default function PageStock() {
<option value="">Alerte : toutes</option>
<option value="seuil">Sous le seuil</option>
</select>
<select
aria-label="Filtrer par fournisseur"
value={fournisseur}
onChange={(e) => setFournisseur(e.target.value)}
>
<option value="">Fournisseur : tous</option>
{fournisseurs.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
<div className="carte" style={{ padding: 0 }}>
<div className="table">
@@ -105,11 +127,72 @@ export default function PageStock() {
Le stock affiché = somme des mouvements (réceptions de BC, entrées manuelles,
consommations d'OT, ajustements motivés). <b>Aucune saisie directe de quantité.</b>
</div>
{can('PARTS', 'create') ? <NouvellePiece ouverte={modale} surFermeture={setModale} /> : null}
{can('PARTS', 'create') ? (
<NouvellePiece ouverte={modale === 'piece'} surFermeture={() => setModale(null)} />
) : null}
{can('PARTS', 'edit') && modale === 'entree' ? (
<EntreeDeStock surFermeture={() => setModale(null)} />
) : null}
</>
);
}
/** Entrée de stock depuis la liste (maquette) : on choisit la pièce ici —
* même mouvement tracé que depuis la fiche. */
function EntreeDeStock({ surFermeture }: { surFermeture: () => void }) {
const { data: parts } = useParts();
const [pieceId, setPieceId] = useState('');
const mouvement = useAddStockMovement(pieceId);
const actives = (parts ?? []).filter((p) => p.isActive);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!pieceId) return;
const donnees = new FormData(e.currentTarget);
mouvement.mutate(
{
kind: 'ENTRY',
quantity: Number(donnees.get('quantity')),
reason: String(donnees.get('reason') ?? '').trim() || undefined,
},
{ onSuccess: surFermeture },
);
};
return (
<Modale titre="Entrée de stock" ouverte surFermeture={surFermeture}>
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
<div className="champ">
<label htmlFor="es-piece">Pièce *</label>
<select id="es-piece" required value={pieceId} onChange={(e) => setPieceId(e.target.value)}>
<option value="">Sélectionner…</option>
{actives.map((p) => (
<option key={p.id} value={p.id}>
{p.reference} — {p.designation} (stock : {p.stock})
</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="es-qte">Quantité *</label>
<input id="es-qte" name="quantity" type="number" required min={1} placeholder="5" />
</div>
<div className="champ">
<label htmlFor="es-motif">Motif (optionnel)</label>
<input id="es-motif" name="reason" placeholder="Ex. : reprise de stock atelier" />
</div>
{mouvement.isError ? <p className="erreur-form">{mouvement.error.message}</p> : null}
<div className="pied">
<Button onClick={surFermeture}>Annuler</Button>
<Button type="submit" variant="prim" disabled={!pieceId || mouvement.isPending}>
Tracer le mouvement
</Button>
</div>
</form>
</Modale>
);
}
function NouvellePiece({
ouverte,
surFermeture,

View File

@@ -28,7 +28,7 @@ export default function PageTableauDeBord() {
const { data: workOrders } = useWorkOrders(peutVoirOT);
const { data: requests } = useRequests();
const { data: preventif } = usePreventiveStatus(peutVoirOT);
const { data: stats } = useAnalyticsSummary(peutVoirStats);
const { data: stats } = useAnalyticsSummary(12, peutVoirStats);
if (!chargé) return null;
if (!peutVoirOT) return <AccueilSansExploitation />;

View File

@@ -49,7 +49,9 @@ export default function PageTiers() {
? p.openOrders
? `${p.openOrders} BC en cours`
: '—'
: '—'}
: p.siteNames.length
? p.siteNames.join(' · ')
: '—'}
</td>
</tr>
))}

View File

@@ -187,7 +187,38 @@ table {
.recherche svg {
width: 15px;
height: 15px;
flex: none;
}
.recherche[data-active] { position: relative; color: var(--encre-3); }
.recherche input {
flex: 1; min-width: 0; border: 0; background: transparent; outline: none;
color: var(--encre); font-size: 13px; font-family: inherit;
}
.recherche input::placeholder { color: var(--encre-3); }
.recherche kbd {
font-family: inherit; font-size: 11px; color: var(--encre-3);
border: 1px solid var(--bordure); border-radius: 5px; padding: 1px 5px;
background: var(--surface);
}
.recherche .resultats {
position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 50;
background: var(--surface); border: 1px solid var(--bordure);
border-radius: var(--rayon); box-shadow: 0 12px 32px rgb(15 23 42 / .14);
padding: 6px; max-height: 420px; overflow-y: auto;
}
.groupe-resultats {
font-size: 10.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
color: var(--encre-3); padding: 8px 10px 4px;
}
.resultat {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 8px 10px; border-radius: 8px; text-align: left;
color: var(--encre); font-size: 13px; cursor: pointer;
}
.resultat[data-actif] { background: var(--primaire-doux); }
.resultat .ref { color: var(--primaire); font-weight: 700; flex: none; }
.resultat .titre { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.resultat .fin { flex: none; color: var(--encre-2); font-size: 12px; display: inline-flex; }
.compte {
margin-left: auto;
display: flex;
@@ -896,6 +927,15 @@ table {
.total { display: flex; justify-content: space-between; font-weight: 700; padding-top: 10px; font-size: 14px; }
/* Bibliothèque */
.zone-depot {
width: 100%; border: 1.5px dashed var(--bordure-forte, var(--bordure));
border-radius: var(--rayon); background: var(--surface);
padding: 16px; color: var(--encre-2); font-size: 13px; text-align: center;
cursor: pointer; transition: border-color .15s, background-color .15s;
}
.zone-depot:hover, .zone-depot[data-survol] {
border-color: var(--primaire); background: var(--primaire-doux); color: var(--encre);
}
.docs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
@media (max-width: 1100px) { .docs { grid-template-columns: repeat(2, 1fr); } }
.doc {