feat(r5): bibliothèque-corpus en tableau (arbitrage de recette du référent)

- La liste des documents passe en tableau, conforme à l'écran 5 de
  maquette-r5 (la bibliothèque sera surtout des PDF) : Document,
  Rattaché à, Indexation, Corpus (ASSETS.edit), actions.
- Les vignettes restent aux cartes « Documents » des fiches OT/ascenseur
  (photos) ; StatutCorpus/InterrupteurCorpus extraits en composants.
- Une image non indexable affiche son interrupteur éteint quel que soit
  l'état stocké — l'interrupteur montre la réalité du corpus.
- e2e adapté aux lignes de tableau, 16/16 Playwright rejoués ; artefact
  de revue pixel mis à jour (arbitrages rendus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-17 21:40:28 +01:00
parent b69c54ed0f
commit cccfaaabc9
5 changed files with 135 additions and 39 deletions

View File

@@ -65,7 +65,7 @@ test('recette R5 : corpus → réindexation → assistant sourcé → refus honn
),
});
await page.getByRole('button', { name: 'Téléverser', exact: true }).last().click();
const vignette = page.locator('.doc', { hasText: fichier });
const vignette = page.locator('tr', { hasText: fichier }); // tableau du corpus (arbitrage référent)
await expect(vignette).toBeVisible();
await expect(vignette.locator('.st')).toHaveText('à indexer'); // jamais indexé à la naissance

View File

@@ -65,7 +65,7 @@ function ApercuVignette({ doc }: { doc: DocumentDto }) {
}
/** Statut corpus (R5, D3) : lisible d'un coup d'œil, jamais ambigu. */
function StatutCorpus({ doc }: { doc: DocumentDto }) {
export function StatutCorpus({ doc }: { doc: DocumentDto }) {
if (doc.contentType !== 'application/pdf') {
return <span className="st exclu">image non indexable</span>;
}
@@ -84,42 +84,42 @@ function StatutCorpus({ doc }: { doc: DocumentDto }) {
);
}
/** Interrupteur d'inclusion au corpus (R5, D3) — PDF seulement, réversible.
* Une image non indexable s'affiche ÉTEINTE quel que soit l'état stocké :
* l'interrupteur montre la réalité du corpus, pas une colonne de base. */
export function InterrupteurCorpus({ doc }: { doc: DocumentDto }) {
const bascule = useSetDocumentCorpus();
return (
<button
type="button"
role="switch"
aria-checked={doc.contentType === 'application/pdf' && doc.inCorpus}
aria-label={doc.inCorpus ? 'Exclure du corpus' : 'Inclure au corpus'}
title={
doc.contentType !== 'application/pdf'
? 'Seuls les PDF sont indexables'
: doc.inCorpus
? 'Exclure du corpus (effectif à la prochaine réindexation)'
: 'Inclure au corpus (effectif à la prochaine réindexation)'
}
className="interrupteur corpus"
disabled={bascule.isPending || doc.contentType !== 'application/pdf'}
onClick={() => bascule.mutate({ id: doc.id, inCorpus: !doc.inCorpus })}
/>
);
}
export function VignetteDoc({
doc,
corpus,
surSuppression,
}: {
doc: DocumentDto;
/** true = l'utilisateur administre le corpus (statut + interrupteur visibles). */
corpus?: boolean;
surSuppression?: (id: string) => void;
}) {
const bascule = useSetDocumentCorpus();
return (
<div className="doc">
<ApercuVignette doc={doc} />
<span className={CLASSE_TYPE[doc.kind] ?? 'type-doc'}>{DOCUMENT_KIND_LABELS[doc.kind]}</span>
{corpus ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StatutCorpus doc={doc} />
<button
type="button"
role="switch"
aria-checked={doc.inCorpus}
aria-label={doc.inCorpus ? 'Exclure du corpus' : 'Inclure au corpus'}
title={
doc.contentType !== 'application/pdf'
? 'Seuls les PDF sont indexables'
: doc.inCorpus
? 'Exclure du corpus (effectif à la prochaine réindexation)'
: 'Inclure au corpus (effectif à la prochaine réindexation)'
}
className="interrupteur corpus"
disabled={bascule.isPending || doc.contentType !== 'application/pdf'}
onClick={() => bascule.mutate({ id: doc.id, inCorpus: !doc.inCorpus })}
/>
</span>
) : null}
<b>{doc.fileName}</b>
<span className="meta">
{doc.assetReference ? `Asc. ${doc.assetReference} · ` : ''}

View File

@@ -6,10 +6,16 @@ import {
type DocumentKind,
} from '@siop/shared';
import { useReindexAssistant } from '@/api/assistant';
import { useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion';
import {
ouvrirDocument,
telechargerDocument,
useDeleteDocument,
useDocuments,
useUploadDocument,
} from '@/api/gestion';
import { useAssetOptions } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { tailleLisible, VignetteDoc } from '@/components/carte-documents';
import { InterrupteurCorpus, StatutCorpus, tailleLisible } from '@/components/carte-documents';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
@@ -121,15 +127,81 @@ export default function PageBibliotheque() {
</button>
) : null}
{documents?.length ? (
<div className="docs">
{documents.map((d) => (
<VignetteDoc
key={d.id}
doc={d}
corpus={administreCorpus}
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
/>
))}
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>Document</th>
<th>Rattaché à</th>
<th>Indexation</th>
{administreCorpus ? <th>Corpus</th> : null}
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{documents.map((d) => (
<tr key={d.id}>
<td>
<b>{d.fileName}</b>
<span className="sous">
{DOCUMENT_KIND_LABELS[d.kind]} · {tailleLisible(d.size)} ·{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(
new Date(d.createdAt),
)}
{d.uploadedByName ? ` · ${d.uploadedByName}` : ''}
</span>
</td>
<td>
{d.assetReference ? `Asc. ${d.assetReference}` : null}
{d.assetReference && d.workOrderReference ? ' · ' : null}
{d.workOrderReference ?? null}
{!d.assetReference && !d.workOrderReference ? '—' : null}
</td>
<td>
<StatutCorpus doc={d} />
</td>
{administreCorpus ? (
<td>
<InterrupteurCorpus doc={d} />
</td>
) : null}
<td style={{ whiteSpace: 'nowrap', textAlign: 'right' }}>
<span style={{ display: 'inline-flex', gap: 10 }}>
<button
type="button"
onClick={() => void ouvrirDocument(d.id)}
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
>
Ouvrir
</button>
<button
type="button"
onClick={() => void telechargerDocument(d.id, d.fileName)}
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
>
Télécharger
</button>
{peutEditer ? (
<button
type="button"
onClick={() => {
if (window.confirm(`Supprimer « ${d.fileName} » ?`)) {
suppression.mutate(d.id);
}
}}
style={{ color: 'var(--danger)', fontWeight: 600, fontSize: 12.5 }}
>
Supprimer
</button>
) : null}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>