mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Compare commits
6 Commits
release/r3
...
release/r4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e8ca6d62 | ||
|
|
c8b3c1769a | ||
|
|
a22ea60f83 | ||
|
|
514fc7c391 | ||
|
|
5c8d5aac92 | ||
|
|
2736a2f3a1 |
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -118,6 +118,21 @@ jobs:
|
|||||||
- run: pnpm --filter @siop/web test
|
- run: pnpm --filter @siop/web test
|
||||||
- run: pnpm --filter @siop/web build
|
- run: pnpm --filter @siop/web build
|
||||||
|
|
||||||
|
mobile:
|
||||||
|
name: mobile (typecheck + tests jest-expo)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm --filter @siop/shared build
|
||||||
|
- run: pnpm --filter @siop/mobile typecheck
|
||||||
|
- run: pnpm --filter @siop/mobile test
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (parcours démo Playwright)
|
name: e2e (parcours démo Playwright)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -180,7 +195,7 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
name: deploy (Dokploy — siop2.apps.enset.top)
|
name: deploy (Dokploy — siop2.apps.enset.top)
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
needs: [lint, contract, api, web, e2e]
|
needs: [lint, contract, api, web, mobile, e2e]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
environment: production
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -54,5 +54,10 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
- ✅ **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright).
|
- ✅ **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright).
|
||||||
- ✅ **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) → 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndic→site via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright.
|
- ✅ **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) → 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndic→site via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright.
|
||||||
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
|
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
|
||||||
- 🔄 **Reprise ici** : redéployer sur Dokploy et vérifier en ligne (migration `r3_recette_fixes` + seed au boot) → ouverture R4 Mobile (Expo, design d'abord — maquettes avant tout code).
|
- ✅ **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file — ni audio ni géoloc en R4).
|
||||||
|
- ✅ **R4 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R4.1 socle mobile** : `apps/mobile` (Expo SDK 57, TS strict), connexion + sélecteur démo ADR-002, tabbar (onglets futurs marqués), « Ma journée » triée priorité/échéance, cache TanStack persisté (lecture hors-ligne D1), jeton SecureStore, client typé du contrat, `CORS_ORIGINS` opt-in côté API (Expo web/debug seulement), 6 tests jest-expo + job CI `mobile`. Vérifié 10/10 en Expo web (connexion démo → Ma journée → hors-ligne/retour).
|
||||||
|
- ✅ **R4.2 — terrain en ligne** : scanner QR réel (expo-camera, `analyseScan` testée, résolution locale D4, QR étrangers refusés), fiche ascenseur (D3, historique scopé), fiche OT (machine à états, coûts figés, garde par `closureBlockers` API), clôture terrain (bilan 6 champs au pouce), préventif → grille cochable (appui long = N/A, `aria-checked`). Écritures en ligne assumées (bandeaux) — la file est R4.3. Vérifié 12/12 en Expo web (dont clôture complète d'un OT de test), 12 tests jest-expo.
|
||||||
|
- ✅ **R4.3 — file & verrou** : verrou optimiste serveur (`baseUpdatedAt` → 409 contextualisé, toute écriture avance la version, web inchangé, ADR-003 sécurité/routage mobile) ; file persistée rejouée dans l'ordre (propagation de version intra-lot, arrêt sur conflit, patchs optimistes, photos D5 compressées en file), écran Synchro & conflits (rejouer sur version à jour / abandonner), préchargement parc+référentiels, purge complète à la déconnexion. Recette « mode avion » 13/13 en Expo web (conflit tranché par l'humain, serveur DONE + bilan), 17 tests jest-expo, 74 tests API.
|
||||||
|
- 🏁 **R4 CLOSE (17/07/2026, tag `release/r4`)** : posé sur décision du référent avec la recette « mode avion » validée 13/13 en Expo web piloté ; **la recette sur téléphone (Expo Go — vrai mode avion, scan caméra) est reportée et reste due avant toute production client mobile**.
|
||||||
|
- 🔄 **Reprise ici** : R5 IA — design d'abord : maquettes (assistant RAG sourcé, suggestion de codes de bilan) + décisions à acter, AUCUN code `apps/ai` avant validation. Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
|
||||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -18,3 +18,6 @@ MINIO_ENDPOINT=localhost
|
|||||||
MINIO_PORT=9000
|
MINIO_PORT=9000
|
||||||
MINIO_ACCESS_KEY=siop
|
MINIO_ACCESS_KEY=siop
|
||||||
MINIO_SECRET_KEY=siop-minio
|
MINIO_SECRET_KEY=siop-minio
|
||||||
|
|
||||||
|
# R4 — origines navigateur autorisées (Expo web / debug mobile). Vide = pas de CORS.
|
||||||
|
CORS_ORIGINS=http://localhost:8081
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const EnvSchema = z.object({
|
|||||||
MINIO_ACCESS_KEY: z.string().default('siop'),
|
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||||
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||||
MINIO_BUCKET: z.string().default('siop2'),
|
MINIO_BUCKET: z.string().default('siop2'),
|
||||||
|
// R4 : origines navigateur autorisées en CORS (Expo web / debug mobile).
|
||||||
|
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx
|
||||||
|
// même-origine, les apps natives n'envoient pas d'Origin.
|
||||||
|
CORS_ORIGINS: z.string().default(''),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof EnvSchema>;
|
export type Env = z.infer<typeof EnvSchema>;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ async function bootstrap() {
|
|||||||
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule.forRoot());
|
const app = await NestFactory.create(AppModule.forRoot());
|
||||||
|
const origines = env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean);
|
||||||
|
if (origines.length) app.enableCors({ origin: origines });
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
await app.listen(env.PORT);
|
await app.listen(env.PORT);
|
||||||
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export class WorkOrdersService {
|
|||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
): Promise<WorkOrderDetail> {
|
): Promise<WorkOrderDetail> {
|
||||||
const detail = await this.get(id, user);
|
const detail = await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
|
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`,
|
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`,
|
||||||
@@ -245,6 +246,7 @@ export class WorkOrdersService {
|
|||||||
await this.prisma.workOrderEvent.create({
|
await this.prisma.workOrderEvent.create({
|
||||||
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
|
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
|
||||||
});
|
});
|
||||||
|
await this.toucher(id);
|
||||||
return this.get(id, user);
|
return this.get(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +286,7 @@ export class WorkOrdersService {
|
|||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
): Promise<WorkOrderDetail> {
|
): Promise<WorkOrderDetail> {
|
||||||
await this.get(id, user);
|
await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
|
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
|
||||||
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
|
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
|
||||||
const valeur = dto[colonne as keyof ReportUpsert];
|
const valeur = dto[colonne as keyof ReportUpsert];
|
||||||
@@ -310,6 +313,7 @@ export class WorkOrdersService {
|
|||||||
update: donnees,
|
update: donnees,
|
||||||
create: { workOrderId: id, ...donnees },
|
create: { workOrderId: id, ...donnees },
|
||||||
});
|
});
|
||||||
|
await this.toucher(id);
|
||||||
return this.get(id, user);
|
return this.get(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +324,7 @@ export class WorkOrdersService {
|
|||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
): Promise<ChecklistItemDto> {
|
): Promise<ChecklistItemDto> {
|
||||||
await this.get(id, user);
|
await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
const { count } = await this.prisma.checklistItem.updateMany({
|
const { count } = await this.prisma.checklistItem.updateMany({
|
||||||
where: { id: itemId, workOrderId: id },
|
where: { id: itemId, workOrderId: id },
|
||||||
data: {
|
data: {
|
||||||
@@ -329,6 +334,7 @@ export class WorkOrdersService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (count === 0) throw new NotFoundException('Tâche inconnue');
|
if (count === 0) throw new NotFoundException('Tâche inconnue');
|
||||||
|
await this.toucher(id);
|
||||||
const item = await this.prisma.checklistItem.findUniqueOrThrow({
|
const item = await this.prisma.checklistItem.findUniqueOrThrow({
|
||||||
where: { id: itemId },
|
where: { id: itemId },
|
||||||
include: { doneBy: true },
|
include: { doneBy: true },
|
||||||
@@ -368,6 +374,7 @@ export class WorkOrdersService {
|
|||||||
byId: user.userId,
|
byId: user.userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.toucher(id);
|
||||||
return this.get(id, user);
|
return this.get(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +405,7 @@ export class WorkOrdersService {
|
|||||||
note: dto.note,
|
note: dto.note,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.toucher(id);
|
||||||
return this.get(id, user);
|
return this.get(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,6 +530,43 @@ export class WorkOrdersService {
|
|||||||
},
|
},
|
||||||
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
||||||
closureBlockers: blockers,
|
closureBlockers: blockers,
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Verrou optimiste (D2 mobile) : si l'appelant fournit la version lue et
|
||||||
|
* que l'OT a changé depuis, 409 avec le contexte (qui, quand) — rien
|
||||||
|
* n'est écrasé en silence. Sans version fournie (web), comportement
|
||||||
|
* inchangé. */
|
||||||
|
private async assertVersion(id: string, baseUpdatedAt?: string): Promise<void> {
|
||||||
|
if (!baseUpdatedAt) return;
|
||||||
|
const wo = await this.prisma.workOrder.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
updatedAt: true,
|
||||||
|
events: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
include: { by: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (wo.updatedAt.getTime() > new Date(baseUpdatedAt).getTime()) {
|
||||||
|
const dernier = wo.events[0];
|
||||||
|
const qui = dernier?.by?.displayName ?? 'quelqu’un';
|
||||||
|
const quand = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(wo.updatedAt);
|
||||||
|
throw new ConflictException(
|
||||||
|
`Conflit de version : cet OT a été modifié par ${qui} à ${quand} — votre saisie n'a pas été appliquée.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toute écriture « secondaire » (coche, bilan, commentaire, coûts) fait
|
||||||
|
* avancer la version de l'OT — sinon le verrou D2 ne verrait rien. */
|
||||||
|
private async toucher(id: string): Promise<void> {
|
||||||
|
await this.prisma.workOrder.update({ where: { id }, data: { updatedAt: new Date() } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,3 +139,109 @@ describe('Recherche globale & corrections de recette R3 (e2e)', () => {
|
|||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Verrou optimiste D2 (e2e) — la version protège les saisies mobiles', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let salma: string;
|
||||||
|
let ahmed: string;
|
||||||
|
let otId: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
const suffix = `verrou-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken as string;
|
||||||
|
};
|
||||||
|
salma = await login('Dispatcher');
|
||||||
|
ahmed = await login('Technicien');
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
const technicien = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
|
||||||
|
const cree = await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({
|
||||||
|
title: `Verrou E2E ${suffix}`,
|
||||||
|
type: 'CORRECTIVE',
|
||||||
|
assetId: a1.id,
|
||||||
|
assigneeIds: [technicien.id],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
otId = cree.body.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('version à jour : la transition passe ; le détail expose updatedAt', async () => {
|
||||||
|
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||||
|
expect(detail.body.updatedAt).toBeTruthy();
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${otId}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'IN_PROGRESS', baseUpdatedAt: detail.body.updatedAt })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OT modifié entre-temps (commentaire) : 409 « Conflit de version » contextualisé', async () => {
|
||||||
|
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||||
|
const versionLue = detail.body.updatedAt as string;
|
||||||
|
// Salma commente pendant qu'Ahmed est « hors-ligne » — la version avance
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${otId}/comments`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ message: 'Réassigné après appel du syndic' })
|
||||||
|
.expect(201);
|
||||||
|
const refus = await http()
|
||||||
|
.post(`/work-orders/${otId}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'ON_HOLD', baseUpdatedAt: versionLue })
|
||||||
|
.expect(409);
|
||||||
|
expect(refus.body.message).toContain('Conflit de version');
|
||||||
|
expect(refus.body.message).toContain('Salma');
|
||||||
|
// L'OT n'a PAS bougé — rien d'écrasé en silence
|
||||||
|
const apres = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||||
|
expect(apres.body.status).toBe('IN_PROGRESS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le bilan et la coche font avancer la version (sinon le verrou est aveugle)', async () => {
|
||||||
|
const avant = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
|
||||||
|
const { body: refs } = await http().get('/reference-values').set(auth(ahmed));
|
||||||
|
const porte = refs.referenceValues.find(
|
||||||
|
(v: { field: string; isActive: boolean }) => v.field === 'DOOR_STATE' && v.isActive,
|
||||||
|
);
|
||||||
|
await http()
|
||||||
|
.put(`/work-orders/${otId}/report`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ doorStateId: porte.id })
|
||||||
|
.expect(200);
|
||||||
|
const apres = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
|
||||||
|
expect(new Date(apres).getTime()).toBeGreaterThan(new Date(avant).getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sans baseUpdatedAt (web) : comportement inchangé, même après modification', async () => {
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${otId}/comments`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ message: 'Nouvelle note' })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${otId}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'ON_HOLD' })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
5
apps/mobile/.claude/settings.json
Normal file
5
apps/mobile/.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"expo@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/mobile/.gitignore
vendored
Normal file
41
apps/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
3
apps/mobile/AGENTS.md
Normal file
3
apps/mobile/AGENTS.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Expo HAS CHANGED
|
||||||
|
|
||||||
|
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
|
||||||
33
apps/mobile/README.md
Normal file
33
apps/mobile/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# @siop/mobile — l'app du technicien (R4)
|
||||||
|
|
||||||
|
Expo (SDK 57), offline-first, périmètre fermé : **Ma journée, scan, préventif, synchro** —
|
||||||
|
la gestion reste web, le demandeur reste sur le portail QR public. Maquettes validées :
|
||||||
|
`docs/02-design/maquettes/maquette-r4.html` (+ décisions D1-D5 au journal du 17/07/2026).
|
||||||
|
|
||||||
|
## Lancer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API locale d'abord (apps/api : pnpm dev), puis :
|
||||||
|
pnpm --filter @siop/mobile start # QR Expo Go (téléphone sur le MÊME réseau)
|
||||||
|
EXPO_PUBLIC_API_URL=http://192.168.x.y:3000 pnpm --filter @siop/mobile start
|
||||||
|
```
|
||||||
|
|
||||||
|
- `EXPO_PUBLIC_API_URL` : URL de l'API vue **depuis le téléphone** (IP LAN, pas localhost).
|
||||||
|
Défaut : `http://localhost:3000` (suffisant pour `expo start --web`).
|
||||||
|
- Vérification navigateur : `expo start --web` + `CORS_ORIGINS=http://localhost:8081`
|
||||||
|
côté API (les apps natives n'envoient pas d'Origin — CORS ne concerne que le web).
|
||||||
|
|
||||||
|
## Ce que porte R4.1 (socle)
|
||||||
|
|
||||||
|
- Connexion e-mail/mot de passe + **sélecteur démo** (ADR-002 : n'existe que si l'API l'expose).
|
||||||
|
- Coquille tabbar (onglets à venir marqués R4.2/R4.3) + **Ma journée** : OT triés
|
||||||
|
priorité puis échéance (`src/lib/journee.ts`, testé), urgence en tête, pastille de synchro.
|
||||||
|
- **Lecture hors-ligne (D1)** : cache TanStack persisté dans AsyncStorage (7 jours),
|
||||||
|
NetInfo pilote `onlineManager` + bandeau. L'écriture en file arrive en R4.3.
|
||||||
|
- Client typé généré depuis `docs/openapi.json` (`pnpm generate:client`) — règle d'or ADR-001.
|
||||||
|
- Jeton dans SecureStore (trousseau) ; AsyncStorage en repli web de dev.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`pnpm --filter @siop/mobile test` (jest-expo — logique pure : tri, tokens) ;
|
||||||
|
`typecheck` et le lint racine s'appliquent (job CI `mobile`).
|
||||||
31
apps/mobile/app.json
Normal file
31
apps/mobile/app.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "SIOP",
|
||||||
|
"slug": "siop2-mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-font",
|
||||||
|
"expo-secure-store"
|
||||||
|
],
|
||||||
|
"scheme": "siop"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/mobile/app/(tabs)/_layout.tsx
Normal file
71
apps/mobile/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
import { Text, type ColorValue } from 'react-native';
|
||||||
|
import { useAssets, useReferenceValues } from '@/api/exploitation';
|
||||||
|
import { useFile } from '@/file/store';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** La tabbar de la maquette R4. Les onglets à venir restent visibles
|
||||||
|
* (périmètre annoncé, même patron que la sidebar web) mais mènent à un
|
||||||
|
* écran « disponible en R4.x ». */
|
||||||
|
|
||||||
|
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
|
||||||
|
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CoquilleTabs() {
|
||||||
|
const t = useTokens();
|
||||||
|
const file = useFile();
|
||||||
|
const conflits = file.some((s) => s.statut === 'CONFLIT');
|
||||||
|
// D1 « lecture locale » : le parc (scan D4) et les référentiels du bilan
|
||||||
|
// se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense.
|
||||||
|
useAssets();
|
||||||
|
useReferenceValues();
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: t.primaire,
|
||||||
|
tabBarInactiveTintColor: t.encre3,
|
||||||
|
tabBarStyle: { backgroundColor: t.surface, borderTopColor: t.bordure },
|
||||||
|
tabBarLabelStyle: { fontFamily: 'Manrope_700Bold', fontSize: 10 },
|
||||||
|
sceneStyle: { backgroundColor: t.fond },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="journee"
|
||||||
|
options={{
|
||||||
|
title: 'Ma journée',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="scanner"
|
||||||
|
options={{
|
||||||
|
title: 'Scanner',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="preventif"
|
||||||
|
options={{
|
||||||
|
title: 'Préventif',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="synchro"
|
||||||
|
options={{
|
||||||
|
title: 'Synchro',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
|
||||||
|
tabBarBadge: file.length || undefined,
|
||||||
|
tabBarBadgeStyle: {
|
||||||
|
backgroundColor: conflits ? t.prioBloque : t.stAttente,
|
||||||
|
color: '#fff',
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
apps/mobile/app/(tabs)/journee.tsx
Normal file
242
apps/mobile/app/(tabs)/journee.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, RefreshControl, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import {
|
||||||
|
WORK_ORDER_PRIORITY_LABELS,
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
WORK_ORDER_TYPE_LABELS,
|
||||||
|
type WorkOrderPriority,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { api, unwrap } from '@/api/client';
|
||||||
|
import { useHorsLigne, useLogout, useMe } from '@/auth/session';
|
||||||
|
import { triJournee } from '@/lib/journee';
|
||||||
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 1 de la maquette R4 : les OT du technicien, priorité puis
|
||||||
|
* échéance, « personne bloquée » en tête. Hors-ligne : le cache persisté
|
||||||
|
* sert la liste, le bandeau l'assume (D1). */
|
||||||
|
|
||||||
|
function useWorkOrders() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['work-orders'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_STATUT: Record<WorkOrderStatus, (t: Tokens) => [string, string]> = {
|
||||||
|
OPEN: (t) => [t.stOuvert, t.stOuvertFond],
|
||||||
|
IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond],
|
||||||
|
ON_HOLD: (t) => [t.stAttente, t.stAttenteFond],
|
||||||
|
DONE: (t) => [t.stTermine, t.stTermineFond],
|
||||||
|
CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond],
|
||||||
|
};
|
||||||
|
const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
|
||||||
|
PERSON_TRAPPED: (t) => t.prioBloque,
|
||||||
|
HIGH: (t) => t.prioHaute,
|
||||||
|
MEDIUM: (t) => t.prioMoyenne,
|
||||||
|
LOW: (t) => t.prioBasse,
|
||||||
|
NONE: () => 'transparent',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PageJournee() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
const ots = triJournee(workOrders ?? []);
|
||||||
|
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
|
||||||
|
const jour = new Intl.DateTimeFormat('fr-FR', { weekday: 'long', day: 'numeric', month: 'short' })
|
||||||
|
.format(new Date());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||||
|
{/* Entête app : marque + pastille synchro + compte */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Text style={{ color: t.safran, fontSize: 15 }}>♦</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
|
||||||
|
SIOP
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre3 }}>
|
||||||
|
Technicien
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginLeft: 'auto' }}>
|
||||||
|
<Text
|
||||||
|
accessibilityLabel="État de synchronisation"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: horsLigne ? t.stAttente : t.stTermine,
|
||||||
|
backgroundColor: horsLigne ? t.stAttenteFond : t.stTermineFond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{horsLigne ? 'Hors-ligne' : 'Synchro à jour'}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Se déconnecter"
|
||||||
|
onLongPress={() => {
|
||||||
|
void logout().then(() => router.replace('/connexion'));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 13,
|
||||||
|
backgroundColor: t.stEncours,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 10 }}>
|
||||||
|
{(me?.displayName ?? '·')
|
||||||
|
.split(' ')
|
||||||
|
.map((m) => m[0])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Ma journée
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 11,
|
||||||
|
color: t.encre3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{jour}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{horsLigne ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
borderColor: t.stAttente,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||||
|
⚠ Hors-ligne — liste du{' '}
|
||||||
|
{dataUpdatedAt
|
||||||
|
? new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(dataUpdatedAt)
|
||||||
|
: '…'}
|
||||||
|
. Vos saisies partiront en file (R4.3).
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{urgences.length ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.prioBloqueFond,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
|
||||||
|
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
|
||||||
|
{urgences.length === 1
|
||||||
|
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
|
||||||
|
: `${urgences.length} personnes bloquées`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={ots}
|
||||||
|
keyExtractor={(o) => o.id}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={isFetching} onRefresh={() => void refetch()} tintColor={t.primaire} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucun OT en cours — tirez pour rafraîchir.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: o }) => {
|
||||||
|
const [enc, fond] = STYLE_STATUT[o.status](t);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${o.reference} — ${o.title}`}
|
||||||
|
onPress={() => router.push(`/ot/${o.id}`)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftColor: STRIE_PRIORITE[o.priority](t),
|
||||||
|
padding: 11,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre2,
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.reference} · {WORK_ORDER_TYPE_LABELS[o.type]}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: enc,
|
||||||
|
backgroundColor: fond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WORK_ORDER_STATUS_LABELS[o.status]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{o.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
Asc. {o.assetReference} — {o.siteName}
|
||||||
|
{o.dueDate
|
||||||
|
? ` · échéance ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(o.dueDate))}`
|
||||||
|
: ''}
|
||||||
|
{o.priority !== 'NONE' && o.priority !== 'PERSON_TRAPPED'
|
||||||
|
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/mobile/app/(tabs)/preventif.tsx
Normal file
76
apps/mobile/app/(tabs)/preventif.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useWorkOrders } from '@/api/exploitation';
|
||||||
|
import { ChipStatut } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Onglet Préventif : mes grilles du moment — chaque tuile mène à la
|
||||||
|
* checklist cochable (écran 6). Les OT viennent déjà scopés par l'API. */
|
||||||
|
export default function PagePreventif() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: workOrders } = useWorkOrders();
|
||||||
|
|
||||||
|
const grilles = (workOrders ?? [])
|
||||||
|
.filter((w) => w.type === 'PREVENTIVE' && w.status !== 'DONE' && w.status !== 'CANCELLED')
|
||||||
|
.sort((a, b) => (a.dueDate ?? '9999').localeCompare(b.dueDate ?? '9999'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Préventif
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
data={grilles}
|
||||||
|
keyExtractor={(w) => w.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucune grille préventive en cours pour vous.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: w }) => (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ot/${w.id}/grille`)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w.reference}
|
||||||
|
</Text>
|
||||||
|
<ChipStatut statut={w.status} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{w.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
Asc. {w.assetReference} — {w.siteName}
|
||||||
|
{w.dueDate
|
||||||
|
? ` · pour le ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(w.dueDate))}`
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
apps/mobile/app/(tabs)/scanner.tsx
Normal file
158
apps/mobile/app/(tabs)/scanner.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Platform, Text, TextInput, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useAssets } from '@/api/exploitation';
|
||||||
|
import { useHorsLigne } from '@/auth/session';
|
||||||
|
import { BoutonTel } from '@/composants/ui';
|
||||||
|
import { analyseScan } from '@/lib/scan';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 4 de la maquette R4 (D4) : le QR de l'étiquette A6 contient l'URL
|
||||||
|
* portail `…/q/REF` — la résolution se fait D'ABORD dans le parc déjà en
|
||||||
|
* cache (donc en sous-sol aussi). Repli : saisie de la référence. */
|
||||||
|
export default function PageScanner() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const [permission, demanderPermission] = useCameraPermissions();
|
||||||
|
const { data: assets, refetch } = useAssets();
|
||||||
|
const [manuel, setManuel] = useState('');
|
||||||
|
const [erreur, setErreur] = useState<string | null>(null);
|
||||||
|
const dernierScan = useRef(0);
|
||||||
|
|
||||||
|
const resoudre = async (brut: string) => {
|
||||||
|
const reference = analyseScan(brut);
|
||||||
|
if (!reference) {
|
||||||
|
setErreur('Ce code n’est pas une étiquette SIOP.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 1 · le parc en cache (fonctionne hors-ligne)
|
||||||
|
let appareil = (assets ?? []).find((a) => a.reference.toUpperCase() === reference);
|
||||||
|
// 2 · sinon, un rafraîchissement si le réseau est là
|
||||||
|
if (!appareil && !horsLigne) {
|
||||||
|
const frais = await refetch();
|
||||||
|
appareil = (frais.data ?? []).find((a) => a.reference.toUpperCase() === reference);
|
||||||
|
}
|
||||||
|
if (!appareil) {
|
||||||
|
setErreur(
|
||||||
|
horsLigne
|
||||||
|
? `« ${reference} » n'est pas dans le parc synchronisé — réessayez au retour du réseau.`
|
||||||
|
: `Aucun appareil « ${reference} » dans le parc.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErreur(null);
|
||||||
|
setManuel('');
|
||||||
|
router.push(`/ascenseur/${appareil.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const surScan = ({ data }: { data: string }) => {
|
||||||
|
const maintenant = Date.now();
|
||||||
|
if (maintenant - dernierScan.current < 1500) return; // anti-rafale
|
||||||
|
dernierScan.current = maintenant;
|
||||||
|
void resoudre(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cameraUtilisable = Platform.OS !== 'web' && permission?.granted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Scanner
|
||||||
|
</Text>
|
||||||
|
{cameraUtilisable ? (
|
||||||
|
<View style={{ flex: 1, borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
<CameraView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||||
|
onBarcodeScanned={surScan}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 12,
|
||||||
|
alignSelf: 'center',
|
||||||
|
color: '#dfe7f2',
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Visez le QR de l’étiquette de cabine
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#131c2c',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 2.5,
|
||||||
|
borderColor: t.safran,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#dfe7f2',
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Platform.OS === 'web'
|
||||||
|
? 'Caméra indisponible sur web — saisissez la référence ci-dessous.'
|
||||||
|
: 'L’appareil photo sert uniquement à lire les étiquettes du parc.'}
|
||||||
|
</Text>
|
||||||
|
{Platform.OS !== 'web' && !permission?.granted ? (
|
||||||
|
<BoutonTel libelle="Autoriser la caméra" surAppui={() => void demanderPermission()} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="Référence de l'appareil"
|
||||||
|
placeholder="Référence (A1, B2…)"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
value={manuel}
|
||||||
|
onChangeText={(v) => {
|
||||||
|
setManuel(v);
|
||||||
|
setErreur(null);
|
||||||
|
}}
|
||||||
|
onSubmitEditing={() => void resoudre(manuel)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BoutonTel libelle="Ouvrir" desactive={!manuel.trim()} surAppui={() => void resoudre(manuel)} />
|
||||||
|
</View>
|
||||||
|
{erreur ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||||
|
{erreur}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
apps/mobile/app/(tabs)/synchro.tsx
Normal file
182
apps/mobile/app/(tabs)/synchro.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useHorsLigne } from '@/auth/session';
|
||||||
|
import { BoutonTel } from '@/composants/ui';
|
||||||
|
import { useFile, type Saisie } from '@/file/store';
|
||||||
|
import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 7 de la maquette R4 : la file VISIBLE et honnête. Rejeu dans
|
||||||
|
* l'ordre ; un conflit (verrou D2) arrête la file sur l'élément, montre le
|
||||||
|
* message de l'API (qui, quand) et VOUS tranchez — rien n'est écrasé ni
|
||||||
|
* perdu en silence. */
|
||||||
|
|
||||||
|
const ICONES: Record<Saisie['type'], string> = {
|
||||||
|
TRANSITION: '🏁',
|
||||||
|
COCHE: '✓',
|
||||||
|
BILAN: '📋',
|
||||||
|
PHOTO: '🖼',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PageSynchro() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const file = useFile();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const conflit = file.find((s) => s.statut === 'CONFLIT');
|
||||||
|
const heure = (iso: string) =>
|
||||||
|
new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(new Date(iso));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Synchro
|
||||||
|
</Text>
|
||||||
|
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
|
||||||
|
{conflit ? 'arrêtée sur le conflit' : file.length ? 'rejouée dans l’ordre' : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{conflit ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.prioBloqueFond,
|
||||||
|
borderColor: t.danger,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_800ExtraBold', fontSize: 13 }}>
|
||||||
|
⚠ Conflit sur {conflit.otReference}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: t.encre, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||||
|
{conflit.erreur ?? 'L’OT a été modifié pendant que vous étiez hors-ligne.'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
|
||||||
|
Votre saisie (« {conflit.libelle} », {heure(conflit.creeA)}) est conservée telle
|
||||||
|
quelle dans la file — rien n’est perdu.
|
||||||
|
</Text>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Voir l’OT à jour"
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => router.push(`/ot/${conflit.otId}`)}
|
||||||
|
/>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Rejouer ma saisie sur la version à jour"
|
||||||
|
surAppui={() => void rejouerSurVersionAJour(conflit.id, queryClient)}
|
||||||
|
/>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Abandonner ma saisie"
|
||||||
|
variante="gris"
|
||||||
|
surAppui={() => void abandonnerSaisie(conflit.id, queryClient)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={file}
|
||||||
|
keyExtractor={(s) => s.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={{ padding: 24, alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 15, color: t.encre }}>
|
||||||
|
Rien en attente
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_400Regular',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: t.encre2,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Toutes vos saisies sont sur le serveur. Les gestes faits hors-ligne apparaîtront
|
||||||
|
ici, rejoués dans l’ordre au retour du réseau.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item: s }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: s.statut === 'CONFLIT' ? t.danger : t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: t.surface2,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 13 }}>{ICONES[s.type]}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text numberOfLines={1} style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
|
||||||
|
{s.libelle}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre2 }}>
|
||||||
|
{s.otReference} · {heure(s.creeA)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
color:
|
||||||
|
s.statut === 'CONFLIT' ? t.danger : s.statut === 'ENVOI' ? t.stOuvert : t.stAttente,
|
||||||
|
backgroundColor:
|
||||||
|
s.statut === 'CONFLIT'
|
||||||
|
? t.prioBloqueFond
|
||||||
|
: s.statut === 'ENVOI'
|
||||||
|
? t.stOuvertFond
|
||||||
|
: t.stAttenteFond,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.statut === 'CONFLIT' ? 'conflit' : s.statut === 'ENVOI' ? 'envoi…' : 'en attente'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{file.length && !horsLigne ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void rejouer(queryClient)}
|
||||||
|
style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 14 }}>
|
||||||
|
Rejouer la file maintenant
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
{horsLigne ? (
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12, textAlign: 'center' }}>
|
||||||
|
Hors-ligne — la file repartira seule au retour du réseau.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
apps/mobile/app/_layout.tsx
Normal file
80
apps/mobile/app/_layout.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Manrope_400Regular,
|
||||||
|
Manrope_600SemiBold,
|
||||||
|
Manrope_700Bold,
|
||||||
|
Manrope_800ExtraBold,
|
||||||
|
useFonts,
|
||||||
|
} from '@expo-google-fonts/manrope';
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
|
||||||
|
import { onlineManager, QueryClient } from '@tanstack/react-query';
|
||||||
|
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { HorsLigneProvider } from '@/auth/session';
|
||||||
|
import { chargerFile } from '@/file/store';
|
||||||
|
import { rejouer } from '@/file/synchro';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Racine de l'app (D1 validée) : le cache TanStack est PERSISTÉ dans
|
||||||
|
* AsyncStorage — les OT lus restent lisibles hors-ligne, y compris après
|
||||||
|
* redémarrage. NetInfo pilote onlineManager (pas de retry dans le vide)
|
||||||
|
* et le drapeau hors-ligne des écrans. */
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60_000,
|
||||||
|
gcTime: 7 * 24 * 3600_000, // une semaine de lecture hors-ligne
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const persister = createAsyncStoragePersister({
|
||||||
|
storage: AsyncStorage,
|
||||||
|
key: 'siop.cache',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function RacineApp() {
|
||||||
|
const t = useTokens();
|
||||||
|
const [horsLigne, setHorsLigne] = useState(false);
|
||||||
|
const [polices] = useFonts({
|
||||||
|
Manrope_400Regular,
|
||||||
|
Manrope_600SemiBold,
|
||||||
|
Manrope_700Bold,
|
||||||
|
Manrope_800ExtraBold,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// D1 : la file survit au redémarrage — on la recharge avant tout.
|
||||||
|
void chargerFile().then(() => rejouer(queryClient));
|
||||||
|
return NetInfo.addEventListener((etat) => {
|
||||||
|
const enLigne = !!etat.isConnected;
|
||||||
|
onlineManager.setOnline(enLigne);
|
||||||
|
setHorsLigne(!enLigne);
|
||||||
|
if (enLigne) void rejouer(queryClient); // le retour du réseau vide la file
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!polices) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{ persister, maxAge: 7 * 24 * 3600_000 }}
|
||||||
|
>
|
||||||
|
<HorsLigneProvider valeur={horsLigne}>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: t.fond },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HorsLigneProvider>
|
||||||
|
</PersistQueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
apps/mobile/app/ascenseur/[id].tsx
Normal file
129
apps/mobile/app/ascenseur/[id].tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { Pressable, ScrollView, Text } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { ASSET_STATUS_LABELS } from '@siop/shared';
|
||||||
|
import { useAsset, useWorkOrders } from '@/api/exploitation';
|
||||||
|
import { Carte, ChipStatut, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||||
|
import { triJournee } from '@/lib/journee';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 5 de la maquette R4 : tout ce qu'il faut DEVANT la machine.
|
||||||
|
* Consultation seule (D3) — l'historique montre ce que le rôle a le droit
|
||||||
|
* de voir (invariant « voir autre », même règle que partout). */
|
||||||
|
export default function PageAscenseur() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: appareil } = useAsset(id);
|
||||||
|
const { data: workOrders } = useWorkOrders();
|
||||||
|
|
||||||
|
if (!appareil) return null;
|
||||||
|
|
||||||
|
const interventions = (workOrders ?? [])
|
||||||
|
.filter((w) => w.assetId === appareil.id)
|
||||||
|
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||||
|
const enCours = triJournee(interventions);
|
||||||
|
const statut = ASSET_STATUS_LABELS[appareil.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche
|
||||||
|
titre={`Asc. ${appareil.reference}`}
|
||||||
|
apres={
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: appareil.status === 'IN_SERVICE' ? t.stTermine : t.stAttente,
|
||||||
|
backgroundColor: appareil.status === 'IN_SERVICE' ? t.stTermineFond : t.stAttenteFond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statut}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 16, color: t.encre }}>
|
||||||
|
{appareil.brand}
|
||||||
|
{appareil.model ? ` ${appareil.model}` : ''} — {appareil.siteName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Carte titre="Identité">
|
||||||
|
<LigneInfo nom="Emplacement" valeur={appareil.locationName} />
|
||||||
|
<LigneInfo nom="Catégorie" valeur={appareil.categoryName} />
|
||||||
|
<LigneInfo
|
||||||
|
nom="Mise en service"
|
||||||
|
valeur={
|
||||||
|
appareil.commissionedAt
|
||||||
|
? new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric' }).format(
|
||||||
|
new Date(appareil.commissionedAt),
|
||||||
|
)
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LigneInfo
|
||||||
|
nom="Charge / niveaux"
|
||||||
|
valeur={`${appareil.loadKg != null ? `${appareil.loadKg} kg` : '—'} · ${appareil.floors != null ? `${appareil.floors} niveaux` : '—'}`}
|
||||||
|
/>
|
||||||
|
{appareil.serialNumber ? (
|
||||||
|
<LigneInfo nom="N° de série" valeur={appareil.serialNumber} />
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<Carte titre="Organes suivis">
|
||||||
|
{appareil.components.length ? (
|
||||||
|
appareil.components.map((c) => (
|
||||||
|
<LigneInfo key={c.id} nom={c.typeName} valeur={c.designation ?? '—'} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucun organe déclaré.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<Carte titre={`Interventions visibles (${interventions.length})`}>
|
||||||
|
{interventions.slice(0, 6).map((w) => (
|
||||||
|
<Pressable
|
||||||
|
key={w.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ot/${w.id}`)}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 3 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.primaire }}>
|
||||||
|
{w.reference}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ flex: 1, fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre }}
|
||||||
|
>
|
||||||
|
{w.title}
|
||||||
|
</Text>
|
||||||
|
<ChipStatut statut={w.status} />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
{!interventions.length ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucune intervention visible pour votre rôle.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
{enCours.length ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ot/${enCours[0]!.id}`)}
|
||||||
|
style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 14 }}>
|
||||||
|
Ouvrir l’OT en cours ({enCours[0]!.reference})
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
apps/mobile/app/connexion.tsx
Normal file
150
apps/mobile/app/connexion.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Connexion mobile — mêmes règles que le web : formulaire e-mail/mot de
|
||||||
|
* passe, et le sélecteur démo (< 3 s pour changer de rôle, ADR-002)
|
||||||
|
* UNIQUEMENT si l'API l'expose. */
|
||||||
|
export default function PageConnexion() {
|
||||||
|
const t = useTokens();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [motDePasse, setMotDePasse] = useState('');
|
||||||
|
const { data: comptes } = useDemoAccounts();
|
||||||
|
const login = useLogin();
|
||||||
|
const demo = useDemoLogin();
|
||||||
|
|
||||||
|
const entrer = () => router.replace('/(tabs)/journee');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1, backgroundColor: t.fond }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 24, paddingTop: 80, gap: 12 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
||||||
|
<Text style={{ color: t.safran, fontSize: 22 }}>♦</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
|
||||||
|
SIOP
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre3 }}>
|
||||||
|
Technicien
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}>
|
||||||
|
Vos ordres de travail, sur le terrain — même sans réseau.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="E-mail"
|
||||||
|
placeholder="E-mail"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 13,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="Mot de passe"
|
||||||
|
placeholder="Mot de passe"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
secureTextEntry
|
||||||
|
value={motDePasse}
|
||||||
|
onChangeText={setMotDePasse}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 13,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={!email || !motDePasse || login.isPending}
|
||||||
|
onPress={() =>
|
||||||
|
login.mutate({ email, password: motDePasse }, { onSuccess: entrer })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 15 }}>
|
||||||
|
{login.isPending ? 'Connexion…' : 'Se connecter'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{login.isError ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 13 }}>
|
||||||
|
{login.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{comptes?.length ? (
|
||||||
|
<View style={{ marginTop: 18, gap: 8 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 1,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mode démo — connexion 1 clic
|
||||||
|
</Text>
|
||||||
|
{comptes.map((c) => (
|
||||||
|
<Pressable
|
||||||
|
key={c.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={demo.isPending}
|
||||||
|
onPress={() => demo.mutate(c.id, { onSuccess: entrer })}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', color: t.encre, fontSize: 14 }}>
|
||||||
|
{c.displayName}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', color: t.encre3, fontSize: 12 }}>
|
||||||
|
{c.roleName}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/mobile/app/index.tsx
Normal file
20
apps/mobile/app/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
|
import { useMe } from '@/auth/session';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Aiguillage : session valide → Ma journée ; sinon → connexion.
|
||||||
|
* (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */
|
||||||
|
export default function Aiguillage() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: me, isLoading } = useMe();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: t.fond }}>
|
||||||
|
<ActivityIndicator color={t.primaire} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return me ? <Redirect href="/(tabs)/journee" /> : <Redirect href="/connexion" />;
|
||||||
|
}
|
||||||
153
apps/mobile/app/ot/[id]/cloture.tsx
Normal file
153
apps/mobile/app/ot/[id]/cloture.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ScrollView, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import {
|
||||||
|
BILAN_FIELD_LABELS,
|
||||||
|
BILAN_FIELDS,
|
||||||
|
REQUIRED_BILAN_FIELDS,
|
||||||
|
type BilanField,
|
||||||
|
type ReportUpsert,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useReferenceValues, useWorkOrder } from '@/api/exploitation';
|
||||||
|
import { useHorsLigne } from '@/auth/session';
|
||||||
|
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
|
||||||
|
import { enfilerBilan, enfilerTransition } from '@/file/actions';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 3 de la maquette R4 : le bilan codé R2 au pouce — 3 champs requis,
|
||||||
|
* garde visible et bloquante. Depuis R4.3, bilan et clôture partent EN
|
||||||
|
* FILE (D1) : hors-ligne, l'OT passe localement à « Terminé (en file) »
|
||||||
|
* et le serveur tranchera au rejeu (verrou D2 si l'OT a bougé). */
|
||||||
|
|
||||||
|
type ChampBilanId =
|
||||||
|
| 'doorStateId'
|
||||||
|
| 'cabinPositionId'
|
||||||
|
| 'anomalyId'
|
||||||
|
| 'externalCauseId'
|
||||||
|
| 'actionTakenId'
|
||||||
|
| 'componentConcernedId';
|
||||||
|
|
||||||
|
const CHAMP_VERS_ID: Record<BilanField, ChampBilanId> = {
|
||||||
|
DOOR_STATE: 'doorStateId',
|
||||||
|
CABIN_POSITION: 'cabinPositionId',
|
||||||
|
ANOMALY: 'anomalyId',
|
||||||
|
EXTERNAL_CAUSE: 'externalCauseId',
|
||||||
|
ACTION_TAKEN: 'actionTakenId',
|
||||||
|
COMPONENT_CONCERNED: 'componentConcernedId',
|
||||||
|
};
|
||||||
|
const CHAMP_VERS_VALEUR = {
|
||||||
|
DOOR_STATE: 'doorState',
|
||||||
|
CABIN_POSITION: 'cabinPosition',
|
||||||
|
ANOMALY: 'anomaly',
|
||||||
|
EXTERNAL_CAUSE: 'externalCause',
|
||||||
|
ACTION_TAKEN: 'actionTaken',
|
||||||
|
COMPONENT_CONCERNED: 'componentConcerned',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function PageCloture() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: ot } = useWorkOrder(id);
|
||||||
|
const { data: valeurs } = useReferenceValues();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [choix, setChoix] = useState<Partial<Record<BilanField, string | null>>>({});
|
||||||
|
|
||||||
|
if (!ot) return null;
|
||||||
|
|
||||||
|
const optionsDe = (champ: BilanField) =>
|
||||||
|
(valeurs ?? []).filter((v: { field: string; isActive: boolean }) => v.field === champ && v.isActive);
|
||||||
|
|
||||||
|
/** Valeur affichée : le choix local s'il existe, sinon le bilan serveur. */
|
||||||
|
const valeurDe = (champ: BilanField): { id: string; label: string } | null => {
|
||||||
|
if (champ in choix) {
|
||||||
|
const vid = choix[champ];
|
||||||
|
if (!vid) return null;
|
||||||
|
const v = (valeurs ?? []).find((x) => x.id === vid);
|
||||||
|
return v ? { id: v.id, label: v.label } : null;
|
||||||
|
}
|
||||||
|
return ot.report?.[CHAMP_VERS_VALEUR[champ]] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const enregistrer = () => {
|
||||||
|
const corps: ReportUpsert = {};
|
||||||
|
const labels: Parameters<typeof enfilerBilan>[3] = {};
|
||||||
|
for (const champ of BILAN_FIELDS) {
|
||||||
|
if (champ in choix) {
|
||||||
|
corps[CHAMP_VERS_ID[champ]] = choix[champ] ?? null;
|
||||||
|
labels[CHAMP_VERS_ID[champ]] = valeurDe(champ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enfilerBilan(queryClient, ot, corps, labels);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloturer = () => {
|
||||||
|
enregistrer();
|
||||||
|
enfilerTransition(queryClient, ot, 'DONE');
|
||||||
|
router.replace(`/ot/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const manquants = REQUIRED_BILAN_FIELDS.filter((c) => !valeurDe(c));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
|
||||||
|
<Carte titre="Bilan d'intervention — requis pour clôturer">
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
{[0, 2, 4].map((rang) => (
|
||||||
|
<View key={rang} style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
{[BILAN_FIELDS[rang]!, BILAN_FIELDS[rang + 1]!].map((champ) => (
|
||||||
|
<ChoixTel
|
||||||
|
key={champ}
|
||||||
|
libelle={BILAN_FIELD_LABELS[champ]}
|
||||||
|
requis={REQUIRED_BILAN_FIELDS.includes(champ)}
|
||||||
|
valeur={valeurDe(champ)}
|
||||||
|
options={optionsDe(champ)}
|
||||||
|
surChoix={(vid) => setChoix((c) => ({ ...c, [champ]: vid }))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{manquants.length ? (
|
||||||
|
<Text style={{ color: t.alerte, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||||
|
⚠ {manquants.map((c) => `« ${BILAN_FIELD_LABELS[c]} »`).join(', ')}{' '}
|
||||||
|
{manquants.length > 1 ? 'manquent' : 'manque'} — la clôture restera bloquée (même garde
|
||||||
|
que le web).
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Enregistrer le bilan"
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => {
|
||||||
|
enregistrer();
|
||||||
|
router.replace(`/ot/${id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BoutonTel
|
||||||
|
libelle={
|
||||||
|
manquants.length
|
||||||
|
? 'Clôturer (bilan incomplet)'
|
||||||
|
: horsLigne
|
||||||
|
? 'Clôturer — partira à la synchro'
|
||||||
|
: "Clôturer l'intervention"
|
||||||
|
}
|
||||||
|
variante={manquants.length ? 'gris' : 'vert'}
|
||||||
|
desactive={manquants.length > 0}
|
||||||
|
surAppui={cloturer}
|
||||||
|
/>
|
||||||
|
{horsLigne ? (
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||||
|
Hors-ligne : la clôture est enregistrée sur le téléphone et sera rejouée telle quelle
|
||||||
|
au retour du réseau. Si l’OT a changé entre-temps, VOUS trancherez (écran Synchro).
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
apps/mobile/app/ot/[id]/grille.tsx
Normal file
145
apps/mobile/app/ot/[id]/grille.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useWorkOrder } from '@/api/exploitation';
|
||||||
|
import { useHorsLigne } from '@/auth/session';
|
||||||
|
import { EnteteFiche } from '@/composants/ui';
|
||||||
|
import { enfilerCoche } from '@/file/actions';
|
||||||
|
import { useFile } from '@/file/store';
|
||||||
|
import { prochainEtat, progression } from '@/lib/checklist';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 6 de la maquette R4 : la grille au pouce. Appui simple = coche,
|
||||||
|
* appui long = non-applicable. Chaque coche part en file INDIVIDUELLEMENT
|
||||||
|
* (D1) : 7 coches faites = 7 coches sauvées, réseau ou pas. */
|
||||||
|
export default function PageGrille() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: ot } = useWorkOrder(id);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const file = useFile();
|
||||||
|
|
||||||
|
if (!ot) return null;
|
||||||
|
const { faits, total, pct } = progression(ot.checklist);
|
||||||
|
const enFile = new Set(
|
||||||
|
file.flatMap((s) => (s.otId === ot.id && s.type === 'COCHE' ? [s.payload.itemId] : [])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const basculer = (item: { id: string; label: string }, etat: Parameters<typeof prochainEtat>[0], versNA = false) => {
|
||||||
|
enfilerCoche(
|
||||||
|
queryClient,
|
||||||
|
ot,
|
||||||
|
item,
|
||||||
|
versNA ? (etat === 'NA' ? 'PENDING' : 'NA') : prochainEtat(etat),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche
|
||||||
|
titre={`Grille — ${ot.assetReference}`}
|
||||||
|
apres={
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 13, color: t.encre2 }}>
|
||||||
|
{faits}/{total}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<View style={{ height: 8, borderRadius: 999, backgroundColor: t.surface2, overflow: 'hidden' }}>
|
||||||
|
<View
|
||||||
|
style={{ width: `${Math.round(pct * 100)}%`, height: '100%', backgroundColor: t.succes }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{horsLigne ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
borderColor: t.stAttente,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||||
|
⚠ Hors-ligne — chaque coche part en file et se synchronisera au retour du réseau.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{ot.checklist.map((item) => (
|
||||||
|
<Pressable
|
||||||
|
key={item.id}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
aria-checked={item.state === 'DONE'}
|
||||||
|
accessibilityLabel={item.label}
|
||||||
|
onPress={() => basculer(item, item.state)}
|
||||||
|
onLongPress={() => basculer(item, item.state, true)}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: item.state === 'DONE' ? t.succes : t.bordureForte,
|
||||||
|
backgroundColor: item.state === 'DONE' ? t.succes : 'transparent',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.state === 'DONE' ? (
|
||||||
|
<Text style={{ color: '#fff', fontSize: 13, fontFamily: 'Manrope_800ExtraBold' }}>✓</Text>
|
||||||
|
) : item.state === 'NA' ? (
|
||||||
|
<Text style={{ color: t.encre3, fontSize: 10, fontFamily: 'Manrope_800ExtraBold' }}>NA</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
color: item.state === 'PENDING' ? t.encre : t.encre2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{enFile.has(item.id) ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 9,
|
||||||
|
color: t.stAttente,
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
en file
|
||||||
|
</Text>
|
||||||
|
) : item.doneBy ? (
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 10.5, color: t.encre3 }}>
|
||||||
|
{item.doneBy.displayName.split(' ')[0]}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
|
||||||
|
Appui simple : cocher / décocher · appui long : non-applicable.
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
apps/mobile/app/ot/[id]/index.tsx
Normal file
196
apps/mobile/app/ot/[id]/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { ScrollView, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import {
|
||||||
|
WORK_ORDER_PRIORITY_LABELS,
|
||||||
|
WORK_ORDER_TYPE_LABELS,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { useWorkOrder } from '@/api/exploitation';
|
||||||
|
import { useHorsLigne } from '@/auth/session';
|
||||||
|
import { BoutonTel, Carte, ChipStatut, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||||
|
import { CartePhotos } from '@/composants/carte-photos';
|
||||||
|
import { enfilerTransition } from '@/file/actions';
|
||||||
|
import { useFile } from '@/file/store';
|
||||||
|
import { progression } from '@/lib/checklist';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('fr-FR');
|
||||||
|
|
||||||
|
/** Écran 2 de la maquette R4 : la même machine à états que le web — UN
|
||||||
|
* bouton principal selon l'état. Depuis R4.3, chaque geste part en file
|
||||||
|
* (D1) : l'écran montre l'état local tout de suite, la synchro suit. */
|
||||||
|
export default function PageFicheOT() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: ot } = useWorkOrder(id);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const file = useFile();
|
||||||
|
|
||||||
|
if (!ot) return null;
|
||||||
|
|
||||||
|
const grille = progression(ot.checklist);
|
||||||
|
const enFile = file.filter((s) => s.otId === ot.id);
|
||||||
|
const peutDemarrer = ot.allowedTransitions.includes('IN_PROGRESS');
|
||||||
|
const peutCloturer = ot.allowedTransitions.includes('DONE');
|
||||||
|
const peutSuspendre = ot.allowedTransitions.includes('ON_HOLD');
|
||||||
|
|
||||||
|
const demarrer = () => enfilerTransition(queryClient, ot, 'IN_PROGRESS');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche
|
||||||
|
titre={ot.reference}
|
||||||
|
apres={
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
{enFile.length ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 10,
|
||||||
|
color: t.stAttente,
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
paddingHorizontal: 7,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{enFile.length} en file
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<ChipStatut statut={ot.status} />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
|
||||||
|
{ot.title}
|
||||||
|
</Text>
|
||||||
|
{horsLigne ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
borderColor: t.stAttente,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||||
|
⚠ Hors-ligne — vos gestes partent en file et se synchroniseront au retour du réseau.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Carte titre="Intervention">
|
||||||
|
<LigneInfo nom="Type" valeur={WORK_ORDER_TYPE_LABELS[ot.type]} />
|
||||||
|
<LigneInfo
|
||||||
|
nom="Équipement"
|
||||||
|
valeur={`Asc. ${ot.assetReference} — ${ot.siteName}`}
|
||||||
|
/>
|
||||||
|
<LigneInfo
|
||||||
|
nom="Priorité"
|
||||||
|
valeur={
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: ot.priority === 'PERSON_TRAPPED' || ot.priority === 'HIGH' ? t.prioHaute : t.encre,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WORK_ORDER_PRIORITY_LABELS[ot.priority]}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LigneInfo
|
||||||
|
nom="Échéance"
|
||||||
|
valeur={
|
||||||
|
ot.dueDate
|
||||||
|
? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(ot.dueDate))
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{ot.request ? (
|
||||||
|
<LigneInfo nom="Demande liée" valeur={`${ot.request.reference} · ${ot.request.requesterLabel}`} />
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<Carte titre={`Pièces & main-d'œuvre — ${fmt.format(ot.costs.total)} MAD`}>
|
||||||
|
{ot.costs.parts.map((p) => (
|
||||||
|
<LigneInfo key={p.id} nom={`${p.designation} × ${p.quantity}`} valeur={`${fmt.format(p.total)}`} />
|
||||||
|
))}
|
||||||
|
{ot.costs.labor.map((l) => (
|
||||||
|
<LigneInfo
|
||||||
|
key={l.id}
|
||||||
|
nom={`${l.displayName} · ${Math.floor(l.minutes / 60)} h ${String(l.minutes % 60).padStart(2, '0')} × ${fmt.format(l.hourlyRate)}/h`}
|
||||||
|
valeur={`${fmt.format(l.total)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!ot.costs.parts.length && !ot.costs.labor.length ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucune consommation ni temps saisi. (Saisies pièces/temps : depuis le web en R4.2.)
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
{ot.checklist.length ? (
|
||||||
|
<Carte titre={`Checklist liée — ${grille.faits}/${grille.total}`}>
|
||||||
|
<BoutonTel
|
||||||
|
libelle={grille.faits === grille.total ? 'Grille complète ✓ — revoir' : 'Cocher la grille'}
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => router.push(`/ot/${ot.id}/grille`)}
|
||||||
|
/>
|
||||||
|
</Carte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ot.closureBlockers.length && (peutCloturer || ot.status === 'IN_PROGRESS') ? (
|
||||||
|
<Text style={{ color: t.alerte, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||||
|
⚠ {ot.closureBlockers.join(' · ')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{peutDemarrer ? (
|
||||||
|
<BoutonTel
|
||||||
|
libelle={ot.status === 'ON_HOLD' ? 'Reprendre' : 'Démarrer'}
|
||||||
|
surAppui={demarrer}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{peutCloturer ? (
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Clôturer l'intervention"
|
||||||
|
variante="vert"
|
||||||
|
surAppui={() => router.push(`/ot/${ot.id}/cloture`)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{peutSuspendre ? (
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Mettre en attente"
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => enfilerTransition(queryClient, ot, 'ON_HOLD')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<CartePhotos ot={ot} />
|
||||||
|
|
||||||
|
<Carte titre="Activité">
|
||||||
|
{ot.events.slice(0, 5).map((e) => (
|
||||||
|
<View key={e.id} style={{ gap: 1 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12, color: t.encre }}>
|
||||||
|
{e.kind}
|
||||||
|
{e.message ? ` — ${e.message}` : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre3 }}>
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||||
|
new Date(e.createdAt),
|
||||||
|
)}
|
||||||
|
{e.by ? ` · ${e.by.displayName}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Carte>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
apps/mobile/assets/android-icon-background.png
Normal file
BIN
apps/mobile/assets/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/assets/android-icon-foreground.png
Normal file
BIN
apps/mobile/assets/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/mobile/assets/android-icon-monochrome.png
Normal file
BIN
apps/mobile/assets/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/mobile/assets/favicon.png
Normal file
BIN
apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/mobile/assets/icon.png
Normal file
BIN
apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/mobile/assets/splash-icon.png
Normal file
BIN
apps/mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
11
apps/mobile/jest.config.js
Normal file
11
apps/mobile/jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/** jest-expo (convention CLAUDE.md) — R4.1 teste la logique pure (tri de
|
||||||
|
* Ma journée, tokens bi-thème) ; les parcours complets passeront par la
|
||||||
|
* recette sur appareil. */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'jest-expo',
|
||||||
|
setupFiles: ['./jest.setup.js'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
|
testMatch: ['**/*.test.ts'],
|
||||||
|
};
|
||||||
4
apps/mobile/jest.setup.js
Normal file
4
apps/mobile/jest.setup.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/* AsyncStorage : mock officiel du paquet (pas de natif sous jest). */
|
||||||
|
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||||
|
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
|
||||||
|
);
|
||||||
51
apps/mobile/package.json
Normal file
51
apps/mobile/package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "@siop/mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo-google-fonts/manrope": "^0.4.2",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-community/netinfo": "12.0.1",
|
||||||
|
"@siop/shared": "workspace:*",
|
||||||
|
"@tanstack/query-async-storage-persister": "^5.101.2",
|
||||||
|
"@tanstack/react-query": "^5.101.2",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.101.2",
|
||||||
|
"expo": "~57.0.6",
|
||||||
|
"expo-camera": "~57.0.3",
|
||||||
|
"expo-constants": "~57.0.5",
|
||||||
|
"expo-font": "~57.0.1",
|
||||||
|
"expo-image-manipulator": "~57.0.4",
|
||||||
|
"expo-image-picker": "~57.0.4",
|
||||||
|
"expo-linking": "~57.0.3",
|
||||||
|
"expo-router": "~57.0.6",
|
||||||
|
"expo-secure-store": "~57.0.1",
|
||||||
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"openapi-fetch": "^0.13.8",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-native": "0.86.0",
|
||||||
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
"react-native-screens": "4.25.2",
|
||||||
|
"react-native-web": "^0.21.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/react": "~19.2.2",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-expo": "^57.0.2",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
|
"react-test-renderer": "^19.2.7",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "jest",
|
||||||
|
"generate:client": "openapi-typescript ../../docs/openapi.json -o src/api/schema.d.ts",
|
||||||
|
"lint": "eslint app src"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
31
apps/mobile/src/api/client.ts
Normal file
31
apps/mobile/src/api/client.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import createClient from 'openapi-fetch';
|
||||||
|
import type { paths } from './schema';
|
||||||
|
import { lireJeton } from './jeton';
|
||||||
|
|
||||||
|
/** Client typé — généré depuis docs/openapi.json (règle d'or ADR-001 :
|
||||||
|
* spec committée, clients régénérés dans le même commit).
|
||||||
|
* EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */
|
||||||
|
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
export const api = createClient<paths>({ baseUrl: API_URL });
|
||||||
|
|
||||||
|
api.use({
|
||||||
|
async onRequest({ request }) {
|
||||||
|
const jeton = await lireJeton();
|
||||||
|
if (jeton) request.headers.set('Authorization', `Bearer ${jeton}`);
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function unwrap<T>(res: {
|
||||||
|
data?: T;
|
||||||
|
error?: unknown;
|
||||||
|
response: Response;
|
||||||
|
}): Promise<T> {
|
||||||
|
if (res.data !== undefined) return res.data;
|
||||||
|
const message =
|
||||||
|
res.error && typeof res.error === 'object' && 'message' in res.error
|
||||||
|
? String((res.error as { message: unknown }).message)
|
||||||
|
: `API injoignable ou refus (${res.response.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
103
apps/mobile/src/api/exploitation.ts
Normal file
103
apps/mobile/src/api/exploitation.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared';
|
||||||
|
import { api, unwrap } from './client';
|
||||||
|
|
||||||
|
/** Hooks R4.2 — mêmes opérations que le web (contrat unique). Les écritures
|
||||||
|
* restent EN LIGNE dans cette release ; la mise en file arrive en R4.3 (D1). */
|
||||||
|
|
||||||
|
export function useWorkOrders() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['work-orders'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkOrder(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['work-orders', id],
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET('/work-orders/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssets() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['assets'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsset(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['assets', id],
|
||||||
|
queryFn: async () => unwrap(await api.GET('/assets/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDocumentsOT(workOrderId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['documents', workOrderId],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await unwrap(
|
||||||
|
await api.GET('/documents', { params: { query: { workOrderId } } }),
|
||||||
|
)
|
||||||
|
).documents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReferenceValues() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['reference-values'],
|
||||||
|
staleTime: 3600_000, // référentiels administrables : stables en journée
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/reference-values'))).referenceValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalideOT(id: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return () =>
|
||||||
|
Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['work-orders', id] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['work-orders'] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransition(id: string) {
|
||||||
|
const invalide = useInvalideOT(id);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { to: WorkOrderStatus; comment?: string }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/work-orders/{id}/transition', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: input,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCocheChecklist(otId: string) {
|
||||||
|
const invalide = useInvalideOT(otId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { itemId: string; state: ChecklistState }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH('/work-orders/{id}/checklist/{itemId}', {
|
||||||
|
params: { path: { id: otId, itemId: input.itemId } },
|
||||||
|
body: { state: input.state },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBilan(otId: string) {
|
||||||
|
const invalide = useInvalideOT(otId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: ReportUpsert) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PUT('/work-orders/{id}/report', { params: { path: { id: otId } }, body }),
|
||||||
|
),
|
||||||
|
onSuccess: invalide,
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/mobile/src/api/jeton.ts
Normal file
22
apps/mobile/src/api/jeton.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
/** Le jeton vit dans le trousseau du téléphone (SecureStore) ; sur web
|
||||||
|
* (vérifications de dev seulement), repli AsyncStorage. */
|
||||||
|
const CLE = 'siop.jeton';
|
||||||
|
const natif = Platform.OS !== 'web';
|
||||||
|
|
||||||
|
export async function lireJeton(): Promise<string | null> {
|
||||||
|
return natif ? SecureStore.getItemAsync(CLE) : AsyncStorage.getItem(CLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ecrireJeton(jeton: string): Promise<void> {
|
||||||
|
if (natif) await SecureStore.setItemAsync(CLE, jeton);
|
||||||
|
else await AsyncStorage.setItem(CLE, jeton);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function effacerJeton(): Promise<void> {
|
||||||
|
if (natif) await SecureStore.deleteItemAsync(CLE);
|
||||||
|
else await AsyncStorage.removeItem(CLE);
|
||||||
|
}
|
||||||
4120
apps/mobile/src/api/schema.d.ts
vendored
Normal file
4120
apps/mobile/src/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
83
apps/mobile/src/auth/session.tsx
Normal file
83
apps/mobile/src/auth/session.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { api, unwrap } from '@/api/client';
|
||||||
|
import { ecrireJeton, effacerJeton } from '@/api/jeton';
|
||||||
|
import { viderFile } from '@/file/store';
|
||||||
|
|
||||||
|
/** Session mobile — mêmes règles que le web : le sélecteur démo n'existe
|
||||||
|
* que si l'API répond (ADR-002 : 404 sinon, une seule source de vérité). */
|
||||||
|
|
||||||
|
export function useMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => unwrap(await api.GET('/users/me')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDemoAccounts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['demo-accounts'],
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.GET('/auth/demo-accounts');
|
||||||
|
if (res.response.status === 404) return null; // démo désactivée
|
||||||
|
return (await unwrap(res)).accounts;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useApresConnexion() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return async (accessToken: string) => {
|
||||||
|
await ecrireJeton(accessToken);
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const apres = useApresConnexion();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { email: string; password: string }) => {
|
||||||
|
const res = await unwrap(await api.POST('/auth/login', { body: input }));
|
||||||
|
await apres(res.accessToken);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDemoLogin() {
|
||||||
|
const apres = useApresConnexion();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userId: string) => {
|
||||||
|
const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } }));
|
||||||
|
await apres(res.accessToken);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return async () => {
|
||||||
|
// Rien ne survit à la session sur l'appareil : jeton (trousseau), file
|
||||||
|
// d'écriture, cache OT persisté (loi 09-08 — minimisation).
|
||||||
|
await effacerJeton();
|
||||||
|
viderFile();
|
||||||
|
queryClient.clear();
|
||||||
|
await AsyncStorage.removeItem('siop.cache');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drapeau hors-ligne partagé (pastille topbar + bandeaux d'écrans). */
|
||||||
|
export const HorsLigneContexte = createContext(false);
|
||||||
|
export const useHorsLigne = () => useContext(HorsLigneContexte);
|
||||||
|
|
||||||
|
export function HorsLigneProvider({
|
||||||
|
valeur,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
valeur: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return <HorsLigneContexte.Provider value={valeur}>{children}</HorsLigneContexte.Provider>;
|
||||||
|
}
|
||||||
40
apps/mobile/src/composants/a-venir.tsx
Normal file
40
apps/mobile/src/composants/a-venir.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran futur du périmètre R4 annoncé — même patron que la sidebar web :
|
||||||
|
* visible, honnête sur sa release, jamais un cul-de-sac silencieux. */
|
||||||
|
export function AVenir({ titre, release, detail }: { titre: string; release: string; detail: string }) {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 8 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 18, color: t.encre }}>
|
||||||
|
{titre}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 11,
|
||||||
|
color: t.safran,
|
||||||
|
backgroundColor: t.safranDoux,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disponible en {release}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_400Regular',
|
||||||
|
fontSize: 13,
|
||||||
|
color: t.encre2,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 280,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/mobile/src/composants/carte-photos.tsx
Normal file
98
apps/mobile/src/composants/carte-photos.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as ImageManipulator from 'expo-image-manipulator';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { Alert, Pressable, Text, View } from 'react-native';
|
||||||
|
import type { WorkOrderDetail } from '@siop/shared';
|
||||||
|
import { useDocumentsOT } from '@/api/exploitation';
|
||||||
|
import { Carte } from '@/composants/ui';
|
||||||
|
import { enfilerPhoto } from '@/file/actions';
|
||||||
|
import { useFile } from '@/file/store';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Photos d'intervention (D5) : compressées côté app (~1 600 px) puis mises
|
||||||
|
* EN FILE comme le reste — rattachées à l'OT dans la bibliothèque R3.
|
||||||
|
* Ni audio ni géolocalisation en R4 (loi 09-08, minimisation). */
|
||||||
|
export function CartePhotos({ ot }: { ot: WorkOrderDetail }) {
|
||||||
|
const t = useTokens();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const file = useFile();
|
||||||
|
const { data: documents } = useDocumentsOT(ot.id);
|
||||||
|
|
||||||
|
const photosServeur = (documents ?? []).filter((d) => d.contentType.startsWith('image/'));
|
||||||
|
const photosEnFile = file.filter((s) => s.otId === ot.id && s.type === 'PHOTO');
|
||||||
|
|
||||||
|
const prendre = async () => {
|
||||||
|
const resultat = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
quality: 0.9,
|
||||||
|
});
|
||||||
|
const image = resultat.assets?.[0];
|
||||||
|
if (!image) return;
|
||||||
|
try {
|
||||||
|
// Compression D5 : jamais un original de 12 Mo dans la file.
|
||||||
|
const contexte = ImageManipulator.ImageManipulator.manipulate(image.uri);
|
||||||
|
if (image.width > 1600) contexte.resize({ width: 1600 });
|
||||||
|
const rendu = await contexte.renderAsync();
|
||||||
|
const sauve = await rendu.saveAsync({
|
||||||
|
compress: 0.7,
|
||||||
|
format: ImageManipulator.SaveFormat.JPEG,
|
||||||
|
});
|
||||||
|
enfilerPhoto(queryClient, ot, {
|
||||||
|
uri: sauve.uri,
|
||||||
|
nom: `intervention-${ot.reference}-${Date.now().toString(36)}.jpg`,
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Photo impossible', e instanceof Error ? e.message : 'Erreur inconnue');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Carte titre={`Photos (${photosServeur.length + photosEnFile.length})`}>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{photosServeur.map((d) => (
|
||||||
|
<View key={d.id} style={vignette(t.surface2, t.bordure)}>
|
||||||
|
<Text style={{ fontSize: 18 }}>🖼</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{photosEnFile.map((s) => (
|
||||||
|
<View key={s.id} style={vignette(t.stAttenteFond, t.stAttente)}>
|
||||||
|
<Text style={{ fontSize: 18 }}>🖼</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 2,
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 8,
|
||||||
|
color: t.stAttente,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
en file
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Ajouter une photo"
|
||||||
|
onPress={() => void prendre()}
|
||||||
|
style={vignette('transparent', t.bordureForte, true)}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 22, color: t.primaire }}>+</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Carte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vignette = (fond: string, bordure: string, pointille = false) =>
|
||||||
|
({
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: fond,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: bordure,
|
||||||
|
borderStyle: pointille ? ('dashed' as const) : ('solid' as const),
|
||||||
|
alignItems: 'center' as const,
|
||||||
|
justifyContent: 'center' as const,
|
||||||
|
}) as const;
|
||||||
265
apps/mobile/src/composants/ui.tsx
Normal file
265
apps/mobile/src/composants/ui.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
import { WORK_ORDER_STATUS_LABELS, type WorkOrderStatus } from '@siop/shared';
|
||||||
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Briques d'écran de la maquette R4 — cartes, chips, boutons, sélecteur. */
|
||||||
|
|
||||||
|
export function Carte({ titre, children }: { titre?: string; children: ReactNode }) {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{titre ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{titre}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LigneInfo({ nom, valeur }: { nom: string; valeur: ReactNode }) {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre2 }}>
|
||||||
|
{nom}
|
||||||
|
</Text>
|
||||||
|
{typeof valeur === 'string' ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: t.encre,
|
||||||
|
flexShrink: 1,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valeur}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
valeur
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_STATUT: Record<WorkOrderStatus, (t: Tokens) => [string, string]> = {
|
||||||
|
OPEN: (t) => [t.stOuvert, t.stOuvertFond],
|
||||||
|
IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond],
|
||||||
|
ON_HOLD: (t) => [t.stAttente, t.stAttenteFond],
|
||||||
|
DONE: (t) => [t.stTermine, t.stTermineFond],
|
||||||
|
CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChipStatut({ statut }: { statut: WorkOrderStatus }) {
|
||||||
|
const t = useTokens();
|
||||||
|
const [encre, fond] = STYLE_STATUT[statut](t);
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: encre,
|
||||||
|
backgroundColor: fond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WORK_ORDER_STATUS_LABELS[statut]}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoutonTel({
|
||||||
|
libelle,
|
||||||
|
variante = 'prim',
|
||||||
|
desactive,
|
||||||
|
surAppui,
|
||||||
|
}: {
|
||||||
|
libelle: string;
|
||||||
|
variante?: 'prim' | 'vert' | 'contour' | 'gris';
|
||||||
|
desactive?: boolean;
|
||||||
|
surAppui: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTokens();
|
||||||
|
const fonds = { prim: t.primaire, vert: t.succes, contour: t.surface, gris: t.bordureForte };
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={desactive}
|
||||||
|
onPress={surAppui}
|
||||||
|
style={{
|
||||||
|
backgroundColor: desactive ? t.bordureForte : fonds[variante],
|
||||||
|
borderWidth: variante === 'contour' ? 1.5 : 0,
|
||||||
|
borderColor: t.primaire,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 14,
|
||||||
|
color: variante === 'contour' && !desactive ? t.primaire : '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{libelle}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnteteFiche({ titre, apres }: { titre: string; apres?: ReactNode }) {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Retour"
|
||||||
|
onPress={() => (router.canGoBack() ? router.back() : router.replace('/(tabs)/journee'))}
|
||||||
|
hitSlop={10}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 22, color: t.primaire, fontFamily: 'Manrope_700Bold' }}>‹</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre, flex: 1 }}
|
||||||
|
>
|
||||||
|
{titre}
|
||||||
|
</Text>
|
||||||
|
{apres}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélecteur au pouce (RN n'a pas de <select>) : un champ qui ouvre une
|
||||||
|
* liste plein écran — utilisé par le bilan codé. */
|
||||||
|
export function ChoixTel({
|
||||||
|
libelle,
|
||||||
|
requis,
|
||||||
|
valeur,
|
||||||
|
options,
|
||||||
|
surChoix,
|
||||||
|
}: {
|
||||||
|
libelle: string;
|
||||||
|
requis?: boolean;
|
||||||
|
valeur: { id: string; label: string } | null;
|
||||||
|
options: { id: string; label: string }[];
|
||||||
|
surChoix: (id: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTokens();
|
||||||
|
const [ouvert, setOuvert] = useState(false);
|
||||||
|
return (
|
||||||
|
<View style={{ gap: 4, flex: 1 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
||||||
|
{libelle} {requis ? <Text style={{ color: t.danger }}>*</Text> : null}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={libelle}
|
||||||
|
onPress={() => setOuvert(true)}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
color: valeur ? t.encre : t.encre3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valeur ? valeur.label : 'Sélectionner…'} ▾
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Modal visible={ouvert} animationType="slide" transparent onRequestClose={() => setOuvert(false)}>
|
||||||
|
<Pressable
|
||||||
|
style={{ flex: 1, backgroundColor: 'rgba(9,14,22,.55)' }}
|
||||||
|
onPress={() => setOuvert(false)}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: '70%',
|
||||||
|
padding: 14,
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 15, color: t.encre }}>
|
||||||
|
{libelle}
|
||||||
|
</Text>
|
||||||
|
<ScrollView>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => {
|
||||||
|
surChoix(null);
|
||||||
|
setOuvert(false);
|
||||||
|
}}
|
||||||
|
style={{ paddingVertical: 11 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 14, color: t.encre3 }}>
|
||||||
|
— (laisser vide)
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{options.map((o) => (
|
||||||
|
<Pressable
|
||||||
|
key={o.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => {
|
||||||
|
surChoix(o.id);
|
||||||
|
setOuvert(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
paddingVertical: 11,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: t.bordure,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 14,
|
||||||
|
color: o.id === valeur?.id ? t.primaire : t.encre,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.label} {o.id === valeur?.id ? '✓' : ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/mobile/src/file/actions.ts
Normal file
125
apps/mobile/src/file/actions.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
WORK_ORDER_TRANSITIONS,
|
||||||
|
type ChecklistState,
|
||||||
|
type ReportUpsert,
|
||||||
|
type WorkOrderDetail,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { enfiler, lireFile } from './store';
|
||||||
|
import { rejouer } from './synchro';
|
||||||
|
|
||||||
|
/** Chaque geste terrain = une saisie en file + un patch OPTIMISTE du cache
|
||||||
|
* (le technicien voit son travail tout de suite, même en mode avion — le
|
||||||
|
* cache persisté garde ce patch après redémarrage). En ligne, la file se
|
||||||
|
* vide dans la foulée : le comportement R4.2 est conservé. */
|
||||||
|
|
||||||
|
function patchDetail(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
otId: string,
|
||||||
|
patch: (ot: WorkOrderDetail) => WorkOrderDetail,
|
||||||
|
): void {
|
||||||
|
queryClient.setQueryData<WorkOrderDetail>(['work-orders', otId], (courant) =>
|
||||||
|
courant ? patch(courant) : courant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enfilerTransition(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
ot: WorkOrderDetail,
|
||||||
|
to: WorkOrderStatus,
|
||||||
|
): void {
|
||||||
|
enfiler({
|
||||||
|
type: 'TRANSITION',
|
||||||
|
otId: ot.id,
|
||||||
|
otReference: ot.reference,
|
||||||
|
libelle:
|
||||||
|
to === 'DONE' ? 'Clôture de l’intervention' : `Passage à « ${WORK_ORDER_STATUS_LABELS[to]} »`,
|
||||||
|
baseUpdatedAt: ot.updatedAt,
|
||||||
|
payload: { to },
|
||||||
|
});
|
||||||
|
patchDetail(queryClient, ot.id, (c) => ({
|
||||||
|
...c,
|
||||||
|
status: to,
|
||||||
|
allowedTransitions: WORK_ORDER_TRANSITIONS[to],
|
||||||
|
}));
|
||||||
|
void rejouer(queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enfilerCoche(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
ot: WorkOrderDetail,
|
||||||
|
item: { id: string; label: string },
|
||||||
|
state: ChecklistState,
|
||||||
|
): void {
|
||||||
|
enfiler({
|
||||||
|
type: 'COCHE',
|
||||||
|
otId: ot.id,
|
||||||
|
otReference: ot.reference,
|
||||||
|
libelle: `Coche « ${item.label} » → ${state === 'DONE' ? 'fait' : state === 'NA' ? 'N/A' : 'à faire'}`,
|
||||||
|
baseUpdatedAt: ot.updatedAt,
|
||||||
|
payload: { itemId: item.id, state },
|
||||||
|
});
|
||||||
|
patchDetail(queryClient, ot.id, (c) => ({
|
||||||
|
...c,
|
||||||
|
checklist: c.checklist.map((i) => (i.id === item.id ? { ...i, state } : i)),
|
||||||
|
}));
|
||||||
|
void rejouer(queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enfilerBilan(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
ot: WorkOrderDetail,
|
||||||
|
corps: Omit<ReportUpsert, 'baseUpdatedAt'>,
|
||||||
|
labels: Partial<Record<keyof ReportUpsert, { id: string; label: string } | null>>,
|
||||||
|
): void {
|
||||||
|
enfiler({
|
||||||
|
type: 'BILAN',
|
||||||
|
otId: ot.id,
|
||||||
|
otReference: ot.reference,
|
||||||
|
libelle: 'Bilan d’intervention codé',
|
||||||
|
baseUpdatedAt: ot.updatedAt,
|
||||||
|
payload: corps,
|
||||||
|
});
|
||||||
|
patchDetail(queryClient, ot.id, (c) => ({
|
||||||
|
...c,
|
||||||
|
report: {
|
||||||
|
note: c.report?.note ?? null,
|
||||||
|
doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null),
|
||||||
|
cabinPosition:
|
||||||
|
labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null),
|
||||||
|
anomaly: labels.anomalyId !== undefined ? (labels.anomalyId ?? null) : (c.report?.anomaly ?? null),
|
||||||
|
externalCause:
|
||||||
|
labels.externalCauseId !== undefined ? (labels.externalCauseId ?? null) : (c.report?.externalCause ?? null),
|
||||||
|
actionTaken:
|
||||||
|
labels.actionTakenId !== undefined ? (labels.actionTakenId ?? null) : (c.report?.actionTaken ?? null),
|
||||||
|
componentConcerned:
|
||||||
|
labels.componentConcernedId !== undefined
|
||||||
|
? (labels.componentConcernedId ?? null)
|
||||||
|
: (c.report?.componentConcerned ?? null),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
void rejouer(queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enfilerPhoto(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
ot: WorkOrderDetail,
|
||||||
|
fichier: { uri: string; nom: string; mime: string },
|
||||||
|
): void {
|
||||||
|
enfiler({
|
||||||
|
type: 'PHOTO',
|
||||||
|
otId: ot.id,
|
||||||
|
otReference: ot.reference,
|
||||||
|
libelle: `Photo — ${fichier.nom}`,
|
||||||
|
baseUpdatedAt: ot.updatedAt,
|
||||||
|
payload: fichier,
|
||||||
|
});
|
||||||
|
void rejouer(queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ce que la file porte encore pour cet OT — chips « en file » des écrans. */
|
||||||
|
export function saisiesPour(otId: string) {
|
||||||
|
return lireFile().filter((s) => s.otId === otId);
|
||||||
|
}
|
||||||
93
apps/mobile/src/file/store.ts
Normal file
93
apps/mobile/src/file/store.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared';
|
||||||
|
|
||||||
|
/** La file d'écriture (D1) : chaque geste hors-ligne devient une saisie
|
||||||
|
* datée, persistée dans AsyncStorage — elle survit au redémarrage — et
|
||||||
|
* rejouée DANS L'ORDRE au retour du réseau. Chaque saisie porte la version
|
||||||
|
* de l'OT lue au moment du geste (D2, verrou optimiste). */
|
||||||
|
|
||||||
|
export type Saisie = {
|
||||||
|
id: string;
|
||||||
|
creeA: string; // ISO — affiché à l'écran Synchro
|
||||||
|
otId: string;
|
||||||
|
otReference: string;
|
||||||
|
libelle: string; // « Coche “Jeu des coulisseaux” »
|
||||||
|
baseUpdatedAt: string; // version lue (D2)
|
||||||
|
statut: 'EN_ATTENTE' | 'ENVOI' | 'CONFLIT';
|
||||||
|
erreur?: string; // message du 409 / refus métier — montré tel quel
|
||||||
|
} & (
|
||||||
|
| { type: 'TRANSITION'; payload: { to: WorkOrderStatus } }
|
||||||
|
| { type: 'COCHE'; payload: { itemId: string; state: ChecklistState } }
|
||||||
|
| { type: 'BILAN'; payload: Omit<ReportUpsert, 'baseUpdatedAt'> }
|
||||||
|
| { type: 'PHOTO'; payload: { uri: string; nom: string; mime: string } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const CLE = 'siop.file.v1';
|
||||||
|
let file: Saisie[] = [];
|
||||||
|
const abonnes = new Set<() => void>();
|
||||||
|
|
||||||
|
function notifier(): void {
|
||||||
|
for (const a of abonnes) a();
|
||||||
|
void AsyncStorage.setItem(CLE, JSON.stringify(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** À l'ouverture de l'app : la file survit au redémarrage (D1). */
|
||||||
|
export async function chargerFile(): Promise<void> {
|
||||||
|
const brut = await AsyncStorage.getItem(CLE);
|
||||||
|
if (brut) {
|
||||||
|
// Un ENVOI interrompu par un crash redevient EN_ATTENTE (rejouable).
|
||||||
|
file = (JSON.parse(brut) as Saisie[]).map((s) =>
|
||||||
|
s.statut === 'ENVOI' ? { ...s, statut: 'EN_ATTENTE' } : s,
|
||||||
|
);
|
||||||
|
for (const a of abonnes) a();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lireFile = (): Saisie[] => file;
|
||||||
|
|
||||||
|
export function enfiler(saisie: Omit<Saisie, 'id' | 'creeA' | 'statut'>): Saisie {
|
||||||
|
const complete = {
|
||||||
|
...saisie,
|
||||||
|
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
creeA: new Date().toISOString(),
|
||||||
|
statut: 'EN_ATTENTE',
|
||||||
|
} as Saisie;
|
||||||
|
file = [...file, complete];
|
||||||
|
notifier();
|
||||||
|
return complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function majSaisie(id: string, patch: Partial<Pick<Saisie, 'statut' | 'erreur' | 'baseUpdatedAt'>>): void {
|
||||||
|
file = file.map((s) => (s.id === id ? ({ ...s, ...patch } as Saisie) : s));
|
||||||
|
notifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retirer(id: string): void {
|
||||||
|
file = file.filter((s) => s.id !== id);
|
||||||
|
notifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFile(): Saisie[] {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(cb) => {
|
||||||
|
abonnes.add(cb);
|
||||||
|
return () => abonnes.delete(cb);
|
||||||
|
},
|
||||||
|
lireFile,
|
||||||
|
lireFile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déconnexion : la file appartient à la SESSION — on la purge (sécurité :
|
||||||
|
* un autre compte sur le même téléphone ne doit jamais rejouer les saisies
|
||||||
|
* du précédent), avec sa persistance. */
|
||||||
|
export function viderFile(): void {
|
||||||
|
file = [];
|
||||||
|
notifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réservé aux tests : repartir d'une file vide (sans persistance). */
|
||||||
|
export function _viderPourTests(): void {
|
||||||
|
file = [];
|
||||||
|
}
|
||||||
93
apps/mobile/src/file/synchro.test.ts
Normal file
93
apps/mobile/src/file/synchro.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
import { enfiler, lireFile, retirer, _viderPourTests, type Saisie } from './store';
|
||||||
|
import { rejouer, type ResultatEnvoi } from './synchro';
|
||||||
|
|
||||||
|
/** Le contrat D1/D2 de la file, testé sans réseau : rejeu DANS L'ORDRE,
|
||||||
|
* arrêt sur conflit (l'humain tranche), reprise après coupure. */
|
||||||
|
|
||||||
|
const qc = new QueryClient();
|
||||||
|
|
||||||
|
const saisie = (libelle: string): Saisie =>
|
||||||
|
enfiler({
|
||||||
|
type: 'TRANSITION',
|
||||||
|
otId: 'ot-1',
|
||||||
|
otReference: 'OT-TEST',
|
||||||
|
libelle,
|
||||||
|
baseUpdatedAt: '2026-07-17T10:00:00.000Z',
|
||||||
|
payload: { to: 'IN_PROGRESS' },
|
||||||
|
}) && lireFile().at(-1)!;
|
||||||
|
|
||||||
|
beforeEach(() => _viderPourTests());
|
||||||
|
|
||||||
|
describe('rejouer (file D1/D2)', () => {
|
||||||
|
it('rejoue dans l’ordre et vide la file quand tout passe', async () => {
|
||||||
|
saisie('a');
|
||||||
|
saisie('b');
|
||||||
|
const envoyes: string[] = [];
|
||||||
|
await rejouer(qc, async (s) => {
|
||||||
|
envoyes.push(s.libelle);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
expect(envoyes).toEqual(['a', 'b']);
|
||||||
|
expect(lireFile()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('conflit : la saisie passe en CONFLIT et la file S’ARRÊTE là — rien n’est perdu', async () => {
|
||||||
|
saisie('a');
|
||||||
|
saisie('b');
|
||||||
|
saisie('c');
|
||||||
|
const envoyer = async (s: Saisie): Promise<ResultatEnvoi> =>
|
||||||
|
s.libelle === 'b'
|
||||||
|
? { ok: false, reseau: false, message: 'Conflit de version : modifié par Salma à 14 h 38' }
|
||||||
|
: { ok: true };
|
||||||
|
await rejouer(qc, envoyer);
|
||||||
|
const file = lireFile();
|
||||||
|
expect(file.map((s) => [s.libelle, s.statut])).toEqual([
|
||||||
|
['b', 'CONFLIT'],
|
||||||
|
['c', 'EN_ATTENTE'], // derrière le conflit : on attend l'humain
|
||||||
|
]);
|
||||||
|
expect(file[0]!.erreur).toContain('Salma');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coupure réseau : tout reste EN_ATTENTE, rejouable au retour', async () => {
|
||||||
|
saisie('a');
|
||||||
|
saisie('b');
|
||||||
|
await rejouer(qc, async () => ({ ok: false, reseau: true, message: 'Réseau indisponible' }));
|
||||||
|
expect(lireFile().map((s) => s.statut)).toEqual(['EN_ATTENTE', 'EN_ATTENTE']);
|
||||||
|
// le réseau revient
|
||||||
|
await rejouer(qc, async () => ({ ok: true }));
|
||||||
|
expect(lireFile()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('après « abandonner » le conflit, le reste de la file repart', async () => {
|
||||||
|
saisie('a');
|
||||||
|
saisie('b');
|
||||||
|
await rejouer(qc, async (s) =>
|
||||||
|
s.libelle === 'a' ? { ok: false, reseau: false, message: 'refus métier' } : { ok: true },
|
||||||
|
);
|
||||||
|
expect(lireFile()).toHaveLength(2);
|
||||||
|
retirer(lireFile()[0]!.id); // l'humain abandonne la saisie en conflit
|
||||||
|
await rejouer(qc, async () => ({ ok: true }));
|
||||||
|
expect(lireFile()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('propagation de version (nos écritures ne se conflictent pas entre elles)', () => {
|
||||||
|
it('au succès, la version fraîche se propage aux saisies restantes du même OT', async () => {
|
||||||
|
saisie('a');
|
||||||
|
saisie('b');
|
||||||
|
await rejouer(qc, async (s) =>
|
||||||
|
s.libelle === 'a' ? { ok: true, nouvelleVersion: '2026-07-17T11:00:00.000Z' } : { ok: true },
|
||||||
|
);
|
||||||
|
// b a été envoyée après propagation — vérifions via un rejeu espion
|
||||||
|
_viderPourTests();
|
||||||
|
saisie('c');
|
||||||
|
saisie('d');
|
||||||
|
const basesVues: string[] = [];
|
||||||
|
await rejouer(qc, async (s) => {
|
||||||
|
basesVues.push(s.baseUpdatedAt);
|
||||||
|
return { ok: true, nouvelleVersion: 'V-FRAICHE' };
|
||||||
|
});
|
||||||
|
expect(basesVues).toEqual(['2026-07-17T10:00:00.000Z', 'V-FRAICHE']);
|
||||||
|
});
|
||||||
|
});
|
||||||
160
apps/mobile/src/file/synchro.ts
Normal file
160
apps/mobile/src/file/synchro.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { lireJeton } from '@/api/jeton';
|
||||||
|
import { API_URL } from '@/api/client';
|
||||||
|
import { lireFile, majSaisie, retirer, type Saisie } from './store';
|
||||||
|
|
||||||
|
/** Rejeu de la file (D1/D2) : dans l'ordre, une saisie à la fois.
|
||||||
|
* - succès → la saisie sort de la file ;
|
||||||
|
* - coupure réseau → tout reste EN_ATTENTE, on réessaiera ;
|
||||||
|
* - refus (409 verrou, garde métier) → la saisie passe en CONFLIT et la
|
||||||
|
* file S'ARRÊTE LÀ : l'humain tranche à l'écran Synchro. */
|
||||||
|
|
||||||
|
export type ResultatEnvoi =
|
||||||
|
| { ok: true; nouvelleVersion?: string }
|
||||||
|
| { ok: false; reseau: boolean; message: string };
|
||||||
|
|
||||||
|
export async function envoyerSaisie(s: Saisie): Promise<ResultatEnvoi> {
|
||||||
|
try {
|
||||||
|
let status: number;
|
||||||
|
let message = '';
|
||||||
|
let nouvelleVersion: string | undefined;
|
||||||
|
if (s.type === 'TRANSITION') {
|
||||||
|
const res = await api.POST('/work-orders/{id}/transition', {
|
||||||
|
params: { path: { id: s.otId } },
|
||||||
|
body: { to: s.payload.to, baseUpdatedAt: s.baseUpdatedAt },
|
||||||
|
});
|
||||||
|
status = res.response.status;
|
||||||
|
message = messageDe(res.error);
|
||||||
|
nouvelleVersion = res.data?.updatedAt;
|
||||||
|
} else if (s.type === 'COCHE') {
|
||||||
|
const res = await api.PATCH('/work-orders/{id}/checklist/{itemId}', {
|
||||||
|
params: { path: { id: s.otId, itemId: s.payload.itemId } },
|
||||||
|
body: { state: s.payload.state, baseUpdatedAt: s.baseUpdatedAt },
|
||||||
|
});
|
||||||
|
status = res.response.status;
|
||||||
|
message = messageDe(res.error);
|
||||||
|
} else if (s.type === 'BILAN') {
|
||||||
|
const res = await api.PUT('/work-orders/{id}/report', {
|
||||||
|
params: { path: { id: s.otId } },
|
||||||
|
body: { ...s.payload, baseUpdatedAt: s.baseUpdatedAt },
|
||||||
|
});
|
||||||
|
status = res.response.status;
|
||||||
|
message = messageDe(res.error);
|
||||||
|
nouvelleVersion = res.data?.updatedAt;
|
||||||
|
} else {
|
||||||
|
// PHOTO — multipart hors client typé (D5) : fichier compressé en file.
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('kind', 'PHOTO');
|
||||||
|
form.append('workOrderId', s.otId);
|
||||||
|
if (s.payload.uri.startsWith('http') || s.payload.uri.startsWith('blob:') || s.payload.uri.startsWith('data:')) {
|
||||||
|
const blob = await (await fetch(s.payload.uri)).blob();
|
||||||
|
form.append('file', new File([blob], s.payload.nom, { type: s.payload.mime }));
|
||||||
|
} else {
|
||||||
|
// URI de fichier natif : React Native sait téléverser {uri, name, type}
|
||||||
|
form.append('file', {
|
||||||
|
uri: s.payload.uri,
|
||||||
|
name: s.payload.nom,
|
||||||
|
type: s.payload.mime,
|
||||||
|
} as unknown as Blob);
|
||||||
|
}
|
||||||
|
const jeton = await lireJeton();
|
||||||
|
const res = await fetch(`${API_URL}/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jeton ? { Authorization: `Bearer ${jeton}` } : undefined,
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
status = res.status;
|
||||||
|
if (!res.ok) {
|
||||||
|
const corps = (await res.json().catch(() => null)) as { message?: string } | null;
|
||||||
|
message = corps?.message ?? `Téléversement refusé (${res.status})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status < 400) {
|
||||||
|
// Coche/photo ne renvoient pas le détail : on relit la version pour
|
||||||
|
// que les saisies suivantes du lot ne se heurtent pas à NOS écritures.
|
||||||
|
if (!nouvelleVersion) {
|
||||||
|
const frais = await api.GET('/work-orders/{id}', { params: { path: { id: s.otId } } });
|
||||||
|
nouvelleVersion = frais.data?.updatedAt;
|
||||||
|
}
|
||||||
|
return { ok: true, nouvelleVersion };
|
||||||
|
}
|
||||||
|
return { ok: false, reseau: false, message: message || `Refus (${status})` };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reseau: true, message: 'Réseau indisponible' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageDe(erreur: unknown): string {
|
||||||
|
return erreur && typeof erreur === 'object' && 'message' in erreur
|
||||||
|
? String((erreur as { message: unknown }).message)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let enCours = false;
|
||||||
|
|
||||||
|
export async function rejouer(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
envoyer: (s: Saisie) => Promise<ResultatEnvoi> = envoyerSaisie,
|
||||||
|
): Promise<void> {
|
||||||
|
if (enCours) return;
|
||||||
|
enCours = true;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const suivante = lireFile().find((s) => s.statut === 'EN_ATTENTE');
|
||||||
|
if (!suivante) break;
|
||||||
|
// Un conflit plus ancien barre la route : l'ordre est la promesse D1.
|
||||||
|
const conflitAvant = lireFile().some(
|
||||||
|
(s) => s.statut === 'CONFLIT' && s.creeA <= suivante.creeA,
|
||||||
|
);
|
||||||
|
if (conflitAvant) break;
|
||||||
|
|
||||||
|
majSaisie(suivante.id, { statut: 'ENVOI' });
|
||||||
|
const resultat = await envoyer(suivante);
|
||||||
|
if (resultat.ok) {
|
||||||
|
retirer(suivante.id);
|
||||||
|
// Notre propre écriture a fait avancer la version : les saisies
|
||||||
|
// restantes du même OT repartent de là (un écart ÉTRANGER ultérieur
|
||||||
|
// restera détecté par le verrou).
|
||||||
|
if (resultat.nouvelleVersion) {
|
||||||
|
for (const s of lireFile()) {
|
||||||
|
if (s.otId === suivante.otId && s.statut === 'EN_ATTENTE') {
|
||||||
|
majSaisie(s.id, { baseUpdatedAt: resultat.nouvelleVersion });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['work-orders'] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['documents'] });
|
||||||
|
} else if (resultat.reseau) {
|
||||||
|
majSaisie(suivante.id, { statut: 'EN_ATTENTE' });
|
||||||
|
break; // le réseau reviendra — rien n'est perdu
|
||||||
|
} else {
|
||||||
|
majSaisie(suivante.id, { statut: 'CONFLIT', erreur: resultat.message });
|
||||||
|
break; // l'humain tranche (D2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
enCours = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résolutions du conflit (maquette écran 7). */
|
||||||
|
export async function rejouerSurVersionAJour(
|
||||||
|
saisieId: string,
|
||||||
|
queryClient: QueryClient,
|
||||||
|
): Promise<void> {
|
||||||
|
const saisie = lireFile().find((s) => s.id === saisieId);
|
||||||
|
if (!saisie) return;
|
||||||
|
const frais = await api.GET('/work-orders/{id}', { params: { path: { id: saisie.otId } } });
|
||||||
|
if (frais.data) {
|
||||||
|
majSaisie(saisieId, { statut: 'EN_ATTENTE', baseUpdatedAt: frais.data.updatedAt, erreur: undefined });
|
||||||
|
await rejouer(queryClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function abandonnerSaisie(saisieId: string, queryClient: QueryClient): Promise<void> {
|
||||||
|
retirer(saisieId);
|
||||||
|
// On recharge la vérité serveur : le patch optimiste local est annulé.
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['work-orders'] });
|
||||||
|
await rejouer(queryClient);
|
||||||
|
}
|
||||||
25
apps/mobile/src/lib/checklist.test.ts
Normal file
25
apps/mobile/src/lib/checklist.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { prochainEtat, progression } from './checklist';
|
||||||
|
|
||||||
|
describe('checklist (écran 6 de la maquette R4)', () => {
|
||||||
|
it('progression : DONE et NA comptent comme traités (règle de la garde R2)', () => {
|
||||||
|
const { faits, total, pct } = progression([
|
||||||
|
{ state: 'DONE' },
|
||||||
|
{ state: 'NA' },
|
||||||
|
{ state: 'PENDING' },
|
||||||
|
{ state: 'PENDING' },
|
||||||
|
]);
|
||||||
|
expect(faits).toBe(2);
|
||||||
|
expect(total).toBe(4);
|
||||||
|
expect(pct).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grille vide : pas de division par zéro', () => {
|
||||||
|
expect(progression([]).pct).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appui simple : PENDING ⇄ DONE ; depuis NA on revient à DONE', () => {
|
||||||
|
expect(prochainEtat('PENDING')).toBe('DONE');
|
||||||
|
expect(prochainEtat('DONE')).toBe('PENDING');
|
||||||
|
expect(prochainEtat('NA')).toBe('DONE');
|
||||||
|
});
|
||||||
|
});
|
||||||
19
apps/mobile/src/lib/checklist.ts
Normal file
19
apps/mobile/src/lib/checklist.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ChecklistState } from '@siop/shared';
|
||||||
|
|
||||||
|
/** Progression d'une grille : cochée OU non-applicable = traitée (même
|
||||||
|
* règle que la garde de clôture R2). */
|
||||||
|
export function progression(items: readonly { state: ChecklistState }[]): {
|
||||||
|
faits: number;
|
||||||
|
total: number;
|
||||||
|
pct: number;
|
||||||
|
} {
|
||||||
|
const total = items.length;
|
||||||
|
const faits = items.filter((i) => i.state !== 'PENDING').length;
|
||||||
|
return { faits, total, pct: total ? faits / total : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le geste au pouce : appui simple PENDING ⇄ DONE ; le N/A passe par
|
||||||
|
* l'appui long (assumé à l'écran). Depuis NA, un appui revient à DONE. */
|
||||||
|
export function prochainEtat(actuel: ChecklistState): ChecklistState {
|
||||||
|
return actuel === 'PENDING' ? 'DONE' : actuel === 'DONE' ? 'PENDING' : 'DONE';
|
||||||
|
}
|
||||||
44
apps/mobile/src/lib/journee.test.ts
Normal file
44
apps/mobile/src/lib/journee.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { triJournee, type OtJournee } from './journee';
|
||||||
|
|
||||||
|
const ot = (sur: Partial<OtJournee>): OtJournee => ({
|
||||||
|
priority: 'NONE',
|
||||||
|
status: 'OPEN',
|
||||||
|
dueDate: null,
|
||||||
|
createdAt: '2026-07-01T08:00:00.000Z',
|
||||||
|
...sur,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('triJournee (écran 1 de la maquette R4)', () => {
|
||||||
|
it('met « personne bloquée » en tête, puis la priorité décroissante', () => {
|
||||||
|
const tri = triJournee([
|
||||||
|
ot({ priority: 'LOW' }),
|
||||||
|
ot({ priority: 'PERSON_TRAPPED' }),
|
||||||
|
ot({ priority: 'HIGH' }),
|
||||||
|
ot({ priority: 'MEDIUM' }),
|
||||||
|
]);
|
||||||
|
expect(tri.map((o) => o.priority)).toEqual(['PERSON_TRAPPED', 'HIGH', 'MEDIUM', 'LOW']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('à priorité égale : échéance la plus proche d’abord, sans échéance en dernier', () => {
|
||||||
|
const tri = triJournee([
|
||||||
|
ot({ priority: 'HIGH', dueDate: null, createdAt: '2026-07-01T08:00:00.000Z' }),
|
||||||
|
ot({ priority: 'HIGH', dueDate: '2026-07-20T00:00:00.000Z' }),
|
||||||
|
ot({ priority: 'HIGH', dueDate: '2026-07-18T00:00:00.000Z' }),
|
||||||
|
]);
|
||||||
|
expect(tri.map((o) => o.dueDate)).toEqual([
|
||||||
|
'2026-07-18T00:00:00.000Z',
|
||||||
|
'2026-07-20T00:00:00.000Z',
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('écarte les OT terminés et annulés — la journée ne montre que l’à-faire', () => {
|
||||||
|
const tri = triJournee([
|
||||||
|
ot({ status: 'DONE' }),
|
||||||
|
ot({ status: 'IN_PROGRESS' }),
|
||||||
|
ot({ status: 'CANCELLED' }),
|
||||||
|
ot({ status: 'ON_HOLD' }),
|
||||||
|
]);
|
||||||
|
expect(tri.map((o) => o.status)).toEqual(['IN_PROGRESS', 'ON_HOLD']);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
apps/mobile/src/lib/journee.ts
Normal file
32
apps/mobile/src/lib/journee.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { WorkOrderPriority, WorkOrderStatus } from '@siop/shared';
|
||||||
|
|
||||||
|
/** Tri de « Ma journée » (maquette R4, écran 1) : priorité décroissante
|
||||||
|
* (personne bloquée en tête) puis échéance la plus proche ; les OT clos
|
||||||
|
* ou annulés n'apparaissent pas. Fonction pure — testée. */
|
||||||
|
|
||||||
|
const POIDS: Record<WorkOrderPriority, number> = {
|
||||||
|
PERSON_TRAPPED: 4,
|
||||||
|
HIGH: 3,
|
||||||
|
MEDIUM: 2,
|
||||||
|
LOW: 1,
|
||||||
|
NONE: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface OtJournee {
|
||||||
|
priority: WorkOrderPriority;
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
dueDate: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triJournee<T extends OtJournee>(ots: readonly T[]): T[] {
|
||||||
|
return ots
|
||||||
|
.filter((o) => o.status !== 'DONE' && o.status !== 'CANCELLED')
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
POIDS[b.priority] - POIDS[a.priority] ||
|
||||||
|
(a.dueDate ? Date.parse(a.dueDate) : Infinity) -
|
||||||
|
(b.dueDate ? Date.parse(b.dueDate) : Infinity) ||
|
||||||
|
Date.parse(a.createdAt) - Date.parse(b.createdAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/mobile/src/lib/scan.test.ts
Normal file
21
apps/mobile/src/lib/scan.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { analyseScan } from './scan';
|
||||||
|
|
||||||
|
describe('analyseScan (D4 — QR des étiquettes A6)', () => {
|
||||||
|
it("extrait la référence de l'URL portail, quel que soit le domaine", () => {
|
||||||
|
expect(analyseScan('https://siop2.apps.enset.top/q/A1')).toBe('A1');
|
||||||
|
expect(analyseScan('http://localhost:5173/q/B2')).toBe('B2');
|
||||||
|
expect(analyseScan('https://client.spelev.ma/q/MC-1')).toBe('MC-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepte une référence tapée à la main, normalisée en majuscules', () => {
|
||||||
|
expect(analyseScan(' a1 ')).toBe('A1');
|
||||||
|
expect(analyseScan('b2')).toBe('B2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse ce qui n’est pas à nous — jamais d’écran blanc sur un QR étranger', () => {
|
||||||
|
expect(analyseScan('https://example.com/promo?x=1')).toBeNull();
|
||||||
|
expect(analyseScan('WIFI:T:WPA;S:box;P:secret;;')).toBeNull();
|
||||||
|
expect(analyseScan('')).toBeNull();
|
||||||
|
expect(analyseScan('référence avec espaces')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/mobile/src/lib/scan.ts
Normal file
16
apps/mobile/src/lib/scan.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/** Analyse du scan (D4) : le QR des étiquettes A6 (R1) encode l'URL du
|
||||||
|
* portail public `…/q/REF`. On accepte aussi une référence tapée à la main.
|
||||||
|
* Retour : la référence normalisée, ou null si le code n'est pas un nôtre. */
|
||||||
|
|
||||||
|
const REF_VALIDE = /^[A-Z0-9][A-Z0-9-]{0,11}$/;
|
||||||
|
|
||||||
|
export function analyseScan(brut: string): string | null {
|
||||||
|
const texte = brut.trim();
|
||||||
|
if (!texte) return null;
|
||||||
|
|
||||||
|
// URL d'étiquette : http(s)://…/q/A1 (quel que soit le domaine d'origine)
|
||||||
|
const url = texte.match(/^https?:\/\/[^\s]+\/q\/([^\s/?#]+)$/i);
|
||||||
|
const candidat = decodeURIComponent(url?.[1] ?? texte).toUpperCase();
|
||||||
|
|
||||||
|
return REF_VALIDE.test(candidat) ? candidat : null;
|
||||||
|
}
|
||||||
20
apps/mobile/src/theme/tokens.test.ts
Normal file
20
apps/mobile/src/theme/tokens.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { CLAIR, SOMBRE } from './tokens';
|
||||||
|
|
||||||
|
describe('tokens SIOP portés en RN', () => {
|
||||||
|
it('le sombre couvre exactement les clés du clair (pas de token orphelin)', () => {
|
||||||
|
expect(Object.keys(SOMBRE).sort()).toEqual(Object.keys(CLAIR).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('réplique les valeurs pivots de docs/02-design/tokens.css', () => {
|
||||||
|
expect(CLAIR.primaire).toBe('#1f4fb8');
|
||||||
|
expect(CLAIR.safran).toBe('#dd8a0b');
|
||||||
|
expect(SOMBRE.fond).toBe('#0f1622');
|
||||||
|
expect(SOMBRE.primaire).toBe('#6e92e8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chaque valeur est une couleur hex ou transparent', () => {
|
||||||
|
for (const jeu of [CLAIR, SOMBRE]) {
|
||||||
|
for (const v of Object.values(jeu)) expect(v).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
80
apps/mobile/src/theme/tokens.ts
Normal file
80
apps/mobile/src/theme/tokens.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useColorScheme } from 'react-native';
|
||||||
|
|
||||||
|
/** Tokens SIOP portés en RN — répliques de docs/02-design/tokens.css
|
||||||
|
* (mêmes valeurs que le web et les maquettes R4, bi-thème). */
|
||||||
|
|
||||||
|
export const CLAIR = {
|
||||||
|
primaire: '#1f4fb8',
|
||||||
|
primaireDoux: '#eaf0fb',
|
||||||
|
safran: '#dd8a0b',
|
||||||
|
safranDoux: '#fdf3e3',
|
||||||
|
fond: '#f5f7fa',
|
||||||
|
surface: '#ffffff',
|
||||||
|
surface2: '#eef2f7',
|
||||||
|
bordure: '#dce3ec',
|
||||||
|
bordureForte: '#b9c4d4',
|
||||||
|
encre: '#1b2534',
|
||||||
|
encre2: '#55647a',
|
||||||
|
encre3: '#8494ab',
|
||||||
|
stOuvert: '#3d6fe0',
|
||||||
|
stOuvertFond: '#e9effc',
|
||||||
|
stEncours: '#6d5bd8',
|
||||||
|
stEncoursFond: '#efecfa',
|
||||||
|
stAttente: '#b96f07',
|
||||||
|
stAttenteFond: '#fbf1df',
|
||||||
|
stTermine: '#178a50',
|
||||||
|
stTermineFond: '#e6f5ec',
|
||||||
|
stAnnule: '#68788f',
|
||||||
|
stAnnuleFond: '#edf0f4',
|
||||||
|
prioBloque: '#d92626',
|
||||||
|
prioBloqueFond: '#fdeaea',
|
||||||
|
prioHaute: '#d92626',
|
||||||
|
prioMoyenne: '#b96f07',
|
||||||
|
prioBasse: '#178a50',
|
||||||
|
prioAucune: '#8494ab',
|
||||||
|
succes: '#178a50',
|
||||||
|
alerte: '#b96f07',
|
||||||
|
danger: '#d92626',
|
||||||
|
navFond: '#16233b',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const SOMBRE: Tokens = {
|
||||||
|
primaire: '#6e92e8',
|
||||||
|
primaireDoux: '#1d2c4c',
|
||||||
|
safran: '#e89a1f',
|
||||||
|
safranDoux: '#33270f',
|
||||||
|
fond: '#0f1622',
|
||||||
|
surface: '#182234',
|
||||||
|
surface2: '#1e2a40',
|
||||||
|
bordure: '#2b3850',
|
||||||
|
bordureForte: '#3d4d6b',
|
||||||
|
encre: '#e8edf5',
|
||||||
|
encre2: '#a7b4c8',
|
||||||
|
encre3: '#6d7d96',
|
||||||
|
stOuvert: '#7da2ee',
|
||||||
|
stOuvertFond: '#1c2a47',
|
||||||
|
stEncours: '#a394ec',
|
||||||
|
stEncoursFond: '#262040',
|
||||||
|
stAttente: '#e0a33c',
|
||||||
|
stAttenteFond: '#322510',
|
||||||
|
stTermine: '#4bc084',
|
||||||
|
stTermineFond: '#12301f',
|
||||||
|
stAnnule: '#93a3ba',
|
||||||
|
stAnnuleFond: '#222d3f',
|
||||||
|
prioBloque: '#f26d6d',
|
||||||
|
prioBloqueFond: '#3a1414',
|
||||||
|
prioHaute: '#f26d6d',
|
||||||
|
prioMoyenne: '#e0a33c',
|
||||||
|
prioBasse: '#4bc084',
|
||||||
|
prioAucune: '#6d7d96',
|
||||||
|
succes: '#4bc084',
|
||||||
|
alerte: '#e0a33c',
|
||||||
|
danger: '#f26d6d',
|
||||||
|
navFond: '#0c1524',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Tokens = { [K in keyof typeof CLAIR]: string };
|
||||||
|
|
||||||
|
export function useTokens(): Tokens {
|
||||||
|
return useColorScheme() === 'dark' ? SOMBRE : CLAIR;
|
||||||
|
}
|
||||||
10
apps/mobile/tsconfig.json
Normal file
10
apps/mobile/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"types": ["jest"],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/web/src/api/schema.d.ts
vendored
8
apps/web/src/api/schema.d.ts
vendored
@@ -1776,6 +1776,8 @@ export interface components {
|
|||||||
};
|
};
|
||||||
allowedTransitions: ("OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED")[];
|
allowedTransitions: ("OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED")[];
|
||||||
closureBlockers: string[];
|
closureBlockers: string[];
|
||||||
|
/** Format: date-time */
|
||||||
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
ConsumePart: {
|
ConsumePart: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1945,6 +1947,8 @@ export interface components {
|
|||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
to: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED";
|
to: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED";
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
baseUpdatedAt?: string;
|
||||||
};
|
};
|
||||||
CommentCreate: {
|
CommentCreate: {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -1960,6 +1964,8 @@ export interface components {
|
|||||||
externalCauseId?: string | null;
|
externalCauseId?: string | null;
|
||||||
actionTakenId?: string | null;
|
actionTakenId?: string | null;
|
||||||
componentConcernedId?: string | null;
|
componentConcernedId?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
baseUpdatedAt?: string;
|
||||||
};
|
};
|
||||||
ChecklistItem: {
|
ChecklistItem: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1978,6 +1984,8 @@ export interface components {
|
|||||||
ChecklistPatch: {
|
ChecklistPatch: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
state: "PENDING" | "DONE" | "NA";
|
state: "PENDING" | "DONE" | "NA";
|
||||||
|
/** Format: date-time */
|
||||||
|
baseUpdatedAt?: string;
|
||||||
};
|
};
|
||||||
RequestsResponse: {
|
RequestsResponse: {
|
||||||
requests: {
|
requests: {
|
||||||
|
|||||||
641
docs/02-design/maquettes/maquette-r4.html
Normal file
641
docs/02-design/maquettes/maquette-r4.html
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SIOP V2 — Maquettes HD · R4 Mobile technicien</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
SIOP V2 — Maquettes HD de la release R4 « Mobile technicien » (design-first).
|
||||||
|
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE EXPO (principe n°1).
|
||||||
|
Cible : l'app du TECHNICIEN (Ahmed) — offline-first, Expo Go.
|
||||||
|
La gestion reste web ; le demandeur reste sur le portail QR public (R2).
|
||||||
|
Tokens répliqués de ../tokens.css ; gabarit téléphone repris de maquette-r3
|
||||||
|
(écran portail) et étendu : tabbar, file de synchro, conflits.
|
||||||
|
Recette type (plan de releases) : mode avion en sous-sol → cocher la grille
|
||||||
|
→ retour réseau → synchro sans perdre une saisie (verrou optimiste).
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
/* ═══ Tokens (répliques de ../tokens.css) ═══ */
|
||||||
|
:root{
|
||||||
|
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
|
||||||
|
--safran:#dd8a0b;--safran-doux:#fdf3e3;
|
||||||
|
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
|
||||||
|
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
|
||||||
|
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
|
||||||
|
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
|
||||||
|
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
|
||||||
|
--st-annule:#68788f;--st-annule-fond:#edf0f4;
|
||||||
|
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
|
||||||
|
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
|
||||||
|
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
|
||||||
|
--graf-1:#3b69d6;--graf-2:#dd8a0b;--graf-3:#1f9e8e;--graf-4:#8a63d2;--graf-grille:#e6ebf2;
|
||||||
|
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
|
||||||
|
--police-ui:"Manrope","Avenir Next","Segoe UI Variable",system-ui,sans-serif;
|
||||||
|
--rayon:10px;--rayon-controle:8px;--ombre-menu:0 8px 24px rgb(16 30 54/.14);
|
||||||
|
--transition:140ms ease-out;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme:dark){:root{
|
||||||
|
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
|
||||||
|
--safran:#e89a1f;--safran-doux:#33270f;
|
||||||
|
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
|
||||||
|
--bordure:#2b3850;--bordure-forte:#3d4d6b;
|
||||||
|
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
|
||||||
|
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
|
||||||
|
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
|
||||||
|
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
|
||||||
|
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
|
||||||
|
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
|
||||||
|
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
|
||||||
|
--graf-1:#5580e4;--graf-2:#c7820a;--graf-3:#26a896;--graf-4:#9673de;--graf-grille:#24304a;
|
||||||
|
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
|
||||||
|
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
|
||||||
|
}}
|
||||||
|
:root[data-theme="light"]{
|
||||||
|
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
|
||||||
|
--safran:#dd8a0b;--safran-doux:#fdf3e3;
|
||||||
|
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
|
||||||
|
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
|
||||||
|
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
|
||||||
|
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
|
||||||
|
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
|
||||||
|
--st-annule:#68788f;--st-annule-fond:#edf0f4;
|
||||||
|
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
|
||||||
|
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
|
||||||
|
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
|
||||||
|
--graf-1:#3b69d6;--graf-2:#dd8a0b;--graf-3:#1f9e8e;--graf-4:#8a63d2;--graf-grille:#e6ebf2;
|
||||||
|
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
|
||||||
|
--ombre-menu:0 8px 24px rgb(16 30 54/.14);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"]{
|
||||||
|
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
|
||||||
|
--safran:#e89a1f;--safran-doux:#33270f;
|
||||||
|
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
|
||||||
|
--bordure:#2b3850;--bordure-forte:#3d4d6b;
|
||||||
|
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
|
||||||
|
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
|
||||||
|
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
|
||||||
|
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
|
||||||
|
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
|
||||||
|
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
|
||||||
|
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
|
||||||
|
--graf-1:#5580e4;--graf-2:#c7820a;--graf-3:#26a896;--graf-4:#9673de;--graf-grille:#24304a;
|
||||||
|
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
|
||||||
|
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Base ═══ */
|
||||||
|
*{box-sizing:border-box;margin:0}
|
||||||
|
html,body{background:var(--fond)}
|
||||||
|
body{font-family:var(--police-ui);color:var(--encre);font-size:14px;line-height:1.5}
|
||||||
|
button{font:inherit;cursor:pointer;border:0;background:none;color:inherit}
|
||||||
|
button:focus-visible,a:focus-visible{outline:2px solid var(--primaire);outline-offset:2px;border-radius:4px}
|
||||||
|
.num{font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
/* ═══ Barre de revue (outil maquette, pas l'app) ═══ */
|
||||||
|
.revue{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:12px;flex-wrap:wrap;
|
||||||
|
padding:10px 16px;background:var(--nav-fond);color:var(--nav-encre);font-size:13px}
|
||||||
|
.revue b{color:var(--nav-encre-vif);letter-spacing:.02em}
|
||||||
|
.revue .onglets{display:flex;gap:4px;flex-wrap:wrap}
|
||||||
|
.revue .onglets button{padding:6px 10px;border-radius:6px;color:var(--nav-encre)}
|
||||||
|
.revue .onglets button:hover{background:rgb(255 255 255/.08);color:var(--nav-encre-vif)}
|
||||||
|
.revue .onglets button[aria-selected="true"]{background:rgb(255 255 255/.12);color:var(--nav-encre-vif);
|
||||||
|
box-shadow:inset 3px 0 0 var(--safran)}
|
||||||
|
.revue .droite{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||||
|
.revue .theme{padding:6px 10px;border:1px solid var(--nav-bordure);border-radius:6px}
|
||||||
|
.note-ecran{max-width:1120px;margin:14px auto 0;padding:0 20px;color:var(--encre-2);font-size:13px}
|
||||||
|
.note-ecran b{color:var(--encre)}
|
||||||
|
|
||||||
|
/* ═══ Écrans (mobile only — pas de coquille web en R4) ═══ */
|
||||||
|
.ecran{display:none;max-width:1120px;margin:18px auto 44px;padding:0 20px}
|
||||||
|
.ecran.actif{display:block}
|
||||||
|
.tels{display:flex;gap:28px;justify-content:center;flex-wrap:wrap}
|
||||||
|
.tel{width:310px;border:1px solid var(--bordure-forte);border-radius:28px;overflow:hidden;background:var(--fond);
|
||||||
|
box-shadow:var(--ombre-menu);display:flex;flex-direction:column}
|
||||||
|
.tel .encoche{height:26px;background:var(--nav-fond);display:flex;justify-content:center;align-items:center;flex:none}
|
||||||
|
.tel .encoche i{width:70px;height:7px;border-radius:999px;background:rgb(255 255 255/.18)}
|
||||||
|
.tel .legende{flex:none;text-align:center;font-size:11.5px;font-weight:700;letter-spacing:.05em;
|
||||||
|
text-transform:uppercase;color:var(--encre-3);padding:8px;background:var(--surface-2)}
|
||||||
|
.ecran-tel{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:560px}
|
||||||
|
|
||||||
|
/* Entête app */
|
||||||
|
.entete-tel{display:flex;align-items:center;gap:8px}
|
||||||
|
.entete-tel .flamme{color:var(--safran);font-size:16px}
|
||||||
|
.entete-tel b{font-size:16px;letter-spacing:.03em}
|
||||||
|
.entete-tel small{color:var(--encre-3);font-size:10.5px;margin-top:3px}
|
||||||
|
.entete-tel .fin{margin-left:auto;display:flex;align-items:center;gap:6px}
|
||||||
|
.avatar-tel{width:26px;height:26px;border-radius:50%;background:var(--st-encours);color:#fff;
|
||||||
|
display:flex;align-items:center;justify-content:center;font-size:10.5px;font-weight:700}
|
||||||
|
.sync{font-size:10.5px;font-weight:700;color:var(--st-termine);background:var(--st-termine-fond);
|
||||||
|
padding:2px 8px;border-radius:999px;white-space:nowrap}
|
||||||
|
.sync.off{color:var(--st-attente);background:var(--st-attente-fond)}
|
||||||
|
.titre-tel{font-size:17px;font-weight:800;display:flex;align-items:center;gap:8px}
|
||||||
|
.titre-tel .filet{margin-left:auto;font-size:11px;color:var(--encre-3);font-weight:600}
|
||||||
|
|
||||||
|
/* Bandeaux */
|
||||||
|
.bandeau-off{display:flex;align-items:center;gap:8px;background:var(--st-attente-fond);color:var(--st-attente);
|
||||||
|
border:1px solid currentColor;border-radius:10px;padding:8px 10px;font-size:12px;font-weight:700}
|
||||||
|
.bandeau-urgence{display:flex;align-items:center;gap:8px;background:var(--prio-bloque-fond);color:var(--prio-bloque);
|
||||||
|
border-radius:10px;padding:8px 10px;font-size:12px;font-weight:800}
|
||||||
|
.point-pulse{width:8px;height:8px;border-radius:50%;background:currentColor;flex:none}
|
||||||
|
|
||||||
|
/* Cartes OT */
|
||||||
|
.carte-ot-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:12px;padding:11px 12px;
|
||||||
|
border-left:4px solid transparent}
|
||||||
|
.carte-ot-tel.bloque{border-left-color:var(--prio-bloque)}
|
||||||
|
.carte-ot-tel.haute{border-left-color:var(--prio-haute)}
|
||||||
|
.carte-ot-tel.moyenne{border-left-color:var(--prio-moyenne)}
|
||||||
|
.carte-ot-tel.basse{border-left-color:var(--prio-basse)}
|
||||||
|
.carte-ot-tel .type-t{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);
|
||||||
|
font-weight:700;display:flex;justify-content:space-between;align-items:center;gap:6px}
|
||||||
|
.carte-ot-tel b{display:block;margin:3px 0 2px;font-size:14px}
|
||||||
|
.carte-ot-tel .ou{color:var(--encre-2);font-size:12px}
|
||||||
|
.st{font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px;white-space:nowrap}
|
||||||
|
.st.ouvert{color:var(--st-ouvert);background:var(--st-ouvert-fond)}
|
||||||
|
.st.encours{color:var(--st-encours);background:var(--st-encours-fond)}
|
||||||
|
.st.attente{color:var(--st-attente);background:var(--st-attente-fond)}
|
||||||
|
.st.termine{color:var(--st-termine);background:var(--st-termine-fond)}
|
||||||
|
.pastille-file{font-size:10.5px;font-weight:700;color:var(--st-attente);background:var(--st-attente-fond);
|
||||||
|
padding:2px 8px;border-radius:999px}
|
||||||
|
|
||||||
|
/* Boutons */
|
||||||
|
.btn-tel{display:flex;justify-content:center;align-items:center;gap:8px;padding:12px;border-radius:10px;
|
||||||
|
font-weight:700;font-size:14.5px;background:var(--primaire);color:#fff;width:100%}
|
||||||
|
.btn-tel.vert{background:var(--succes)}
|
||||||
|
.btn-tel.gris{background:var(--bordure-forte);color:var(--surface)}
|
||||||
|
.btn-tel.contour{background:var(--surface);color:var(--primaire);border:1.5px solid var(--primaire)}
|
||||||
|
.paire-boutons{display:flex;gap:8px}
|
||||||
|
.paire-boutons .btn-tel{padding:10px;font-size:13px}
|
||||||
|
|
||||||
|
/* Fiche : cartes d'info */
|
||||||
|
.carte-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:12px;padding:12px;
|
||||||
|
display:flex;flex-direction:column;gap:8px}
|
||||||
|
.carte-tel h4{font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--encre-2)}
|
||||||
|
.ligne-info{display:flex;justify-content:space-between;gap:10px;font-size:12.5px}
|
||||||
|
.ligne-info span:first-child{color:var(--encre-2)}
|
||||||
|
.ligne-info b{text-align:right}
|
||||||
|
.garde{font-size:11.5px;color:var(--alerte);font-weight:600}
|
||||||
|
|
||||||
|
/* Bilan codé (selects) */
|
||||||
|
.champ-tel{display:flex;flex-direction:column;gap:4px}
|
||||||
|
.champ-tel label{font-size:11px;font-weight:700;color:var(--encre-2)}
|
||||||
|
.champ-tel label em{color:var(--danger);font-style:normal}
|
||||||
|
.select-tel{border:1.5px solid var(--bordure-forte);border-radius:9px;background:var(--surface);
|
||||||
|
padding:9px 10px;font-size:13px;color:var(--encre);display:flex;justify-content:space-between;align-items:center}
|
||||||
|
.select-tel.vide{color:var(--encre-3)}
|
||||||
|
.grille-2-tel{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||||
|
|
||||||
|
/* Checklist */
|
||||||
|
.progress{height:8px;border-radius:999px;background:var(--surface-2);overflow:hidden}
|
||||||
|
.progress i{display:block;height:100%;border-radius:999px;background:var(--succes);width:58%}
|
||||||
|
.check-item{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--bordure);
|
||||||
|
border-radius:10px;padding:10px 12px;font-size:13px}
|
||||||
|
.check-item .case{width:20px;height:20px;border-radius:6px;border:2px solid var(--bordure-forte);flex:none}
|
||||||
|
.check-item.fait .case{background:var(--succes);border-color:var(--succes);position:relative}
|
||||||
|
.check-item.fait .case::after{content:'✓';color:#fff;font-size:13px;font-weight:800;position:absolute;inset:0;
|
||||||
|
display:flex;align-items:center;justify-content:center}
|
||||||
|
.check-item.fait{color:var(--encre-2)}
|
||||||
|
.check-item .file-chip{margin-left:auto}
|
||||||
|
|
||||||
|
/* Scan */
|
||||||
|
.viseur{flex:1;min-height:340px;border-radius:12px;background:
|
||||||
|
radial-gradient(circle at 50% 45%, #2c3a52 0%, #131c2c 70%);position:relative;display:flex;
|
||||||
|
align-items:center;justify-content:center}
|
||||||
|
.viseur .cadre-qr{width:170px;height:170px;border-radius:14px;border:2.5px solid var(--safran);
|
||||||
|
box-shadow:0 0 0 2000px rgb(9 14 22/.45)}
|
||||||
|
.viseur p{position:absolute;bottom:14px;color:#dfe7f2;font-size:12.5px}
|
||||||
|
|
||||||
|
/* Historique fiche ascenseur */
|
||||||
|
.hist-item{display:flex;gap:8px;align-items:baseline;font-size:12.5px;padding:7px 0;border-top:1px solid var(--bordure)}
|
||||||
|
.hist-item:first-of-type{border-top:0}
|
||||||
|
.hist-item .ref{color:var(--primaire);font-weight:700;flex:none}
|
||||||
|
.hist-item .quand{margin-left:auto;color:var(--encre-3);font-size:11px;white-space:nowrap}
|
||||||
|
|
||||||
|
/* File de synchro */
|
||||||
|
.file-item{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--bordure);
|
||||||
|
border-radius:10px;padding:10px 12px;font-size:12.5px}
|
||||||
|
.file-item .ico{width:28px;height:28px;border-radius:8px;background:var(--surface-2);display:flex;
|
||||||
|
align-items:center;justify-content:center;flex:none;font-size:14px}
|
||||||
|
.file-item .quoi b{display:block;font-size:12.5px}
|
||||||
|
.file-item .quoi span{color:var(--encre-2);font-size:11px}
|
||||||
|
.file-item .etat{margin-left:auto;font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px;white-space:nowrap}
|
||||||
|
.etat.attente{color:var(--st-attente);background:var(--st-attente-fond)}
|
||||||
|
.etat.envoi{color:var(--info);background:var(--st-ouvert-fond)}
|
||||||
|
.etat.fait{color:var(--st-termine);background:var(--st-termine-fond)}
|
||||||
|
.etat.conflit{color:var(--danger);background:var(--prio-bloque-fond)}
|
||||||
|
.carte-conflit{background:var(--prio-bloque-fond);border:1.5px solid var(--danger);border-radius:12px;
|
||||||
|
padding:12px;display:flex;flex-direction:column;gap:8px}
|
||||||
|
.carte-conflit h4{color:var(--danger);font-size:12.5px;display:flex;gap:6px;align-items:center}
|
||||||
|
.carte-conflit p{font-size:12px;color:var(--encre)}
|
||||||
|
.carte-conflit small{font-size:11px;color:var(--encre-2)}
|
||||||
|
|
||||||
|
/* Tabbar */
|
||||||
|
.tabbar{margin-top:auto;display:flex;border-top:1px solid var(--bordure);background:var(--surface);
|
||||||
|
border-radius:0 0 0 0;padding:6px 4px 8px}
|
||||||
|
.tabbar a{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;font-size:9.5px;
|
||||||
|
font-weight:700;color:var(--encre-3);text-decoration:none;position:relative}
|
||||||
|
.tabbar a.actif{color:var(--primaire)}
|
||||||
|
.tabbar a .pic{font-size:17px;line-height:1.2}
|
||||||
|
.tabbar .badge-tab{position:absolute;top:-3px;right:calc(50% - 16px);background:var(--st-attente);
|
||||||
|
color:#fff;font-size:9px;font-weight:800;padding:0 5px;border-radius:999px}
|
||||||
|
.tabbar .badge-tab.rouge{background:var(--prio-bloque)}
|
||||||
|
|
||||||
|
/* Photos jointes */
|
||||||
|
.photos-tel{display:flex;gap:8px}
|
||||||
|
.photo-tel{width:56px;height:56px;border-radius:9px;background:var(--surface-2);border:1px solid var(--bordure);
|
||||||
|
display:flex;align-items:center;justify-content:center;font-size:19px;position:relative}
|
||||||
|
.photo-tel .file-chip{position:absolute;bottom:-6px;right:-6px}
|
||||||
|
.file-chip{font-size:9px;font-weight:800;color:var(--st-attente);background:var(--st-attente-fond);
|
||||||
|
border:1px solid currentColor;padding:0 5px;border-radius:999px}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<header class="revue">
|
||||||
|
<b>SIOP · Maquettes R4 — Mobile technicien</b>
|
||||||
|
<nav class="onglets" role="tablist" aria-label="Écrans">
|
||||||
|
<button role="tab" aria-selected="true" data-cible="jour">1 · Ma journée</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="fiche-ot">2 · Fiche OT</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="cloture">3 · Clôture terrain</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="scan">4 · Scan QR</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="ascenseur">5 · Fiche ascenseur</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="checklist">6 · Checklist du mois</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="synchro">7 · Synchro & conflits</button>
|
||||||
|
</nav>
|
||||||
|
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ═══ 1 · MA JOURNÉE ═══ -->
|
||||||
|
<p class="note-ecran" data-note="jour"><b>Intention :</b> l'app s'ouvre sur les OT d'Ahmed, triés
|
||||||
|
<b>priorité puis échéance</b> — urgence « personne bloquée » toujours en tête. Le même écran vit
|
||||||
|
<b>en ligne et hors-ligne</b> : hors-ligne, rien ne disparaît, un bandeau l'assume et la tabbar
|
||||||
|
compte les saisies en file. La pastille de synchro est permanente (vert = à jour, ambre = en attente).</p>
|
||||||
|
<section class="ecran actif" id="jour">
|
||||||
|
<div class="tels">
|
||||||
|
<div class="tel">
|
||||||
|
<div class="encoche"><i></i></div>
|
||||||
|
<div class="legende">En ligne — à jour</div>
|
||||||
|
<div class="ecran-tel">
|
||||||
|
<div class="entete-tel">
|
||||||
|
<span class="flamme">♦</span><b>SIOP</b>
|
||||||
|
<small>Technicien</small>
|
||||||
|
<span class="fin"><span class="sync">Synchro à jour</span><span class="avatar-tel">AB</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="titre-tel">Ma journée <span class="filet">jeudi 17 juil.</span></div>
|
||||||
|
<div class="bandeau-urgence"><span class="point-pulse"></span>1 personne bloquée — B2 · Tour Atlas</div>
|
||||||
|
<div class="carte-ot-tel bloque">
|
||||||
|
<div class="type-t">OT-2026-0342 · Dépannage <span class="st ouvert">Ouvert</span></div>
|
||||||
|
<b>Personne bloquée en cabine</b>
|
||||||
|
<div class="ou">Asc. B2 — Tour Atlas · Aïn Diab</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte-ot-tel haute">
|
||||||
|
<div class="type-t">OT-2026-0341 · Dépannage <span class="st encours">En cours</span></div>
|
||||||
|
<b>Bruit anormal en gaine</b>
|
||||||
|
<div class="ou">Asc. A1 — Résidence Al Manar · échéance 18 juil.</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte-ot-tel moyenne">
|
||||||
|
<div class="type-t">OT-2026-0336 · Préventif <span class="st ouvert">Ouvert</span></div>
|
||||||
|
<b>Contrôle mensuel — juillet</b>
|
||||||
|
<div class="ou">Asc. A2 — Résidence Al Manar · grille 0/12</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte-ot-tel basse">
|
||||||
|
<div class="type-t">OT-2026-0329 · Travaux <span class="st attente">En attente</span></div>
|
||||||
|
<b>Remplacement coulisseaux</b>
|
||||||
|
<div class="ou">Asc. C1 — Anfa Place · pièce commandée (BC-2026-0024)</div>
|
||||||
|
</div>
|
||||||
|
<nav class="tabbar">
|
||||||
|
<a class="actif" href="#"><span class="pic">☰</span>Ma journée</a>
|
||||||
|
<a href="#"><span class="pic">▣</span>Scanner</a>
|
||||||
|
<a href="#"><span class="pic">✓</span>Préventif</a>
|
||||||
|
<a href="#"><span class="pic">⇅</span>Synchro</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tel">
|
||||||
|
<div class="encoche"><i></i></div>
|
||||||
|
<div class="legende">Hors-ligne — sous-sol</div>
|
||||||
|
<div class="ecran-tel">
|
||||||
|
<div class="entete-tel">
|
||||||
|
<span class="flamme">♦</span><b>SIOP</b>
|
||||||
|
<small>Technicien</small>
|
||||||
|
<span class="fin"><span class="sync off">Hors-ligne</span><span class="avatar-tel">AB</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="titre-tel">Ma journée <span class="filet">jeudi 17 juil.</span></div>
|
||||||
|
<div class="bandeau-off">⚠ Hors-ligne — vos saisies partent en file et se synchroniseront au retour du réseau.</div>
|
||||||
|
<div class="carte-ot-tel haute">
|
||||||
|
<div class="type-t">OT-2026-0341 · Dépannage <span class="st encours">En cours</span></div>
|
||||||
|
<b>Bruit anormal en gaine</b>
|
||||||
|
<div class="ou">Asc. A1 — Résidence Al Manar · échéance 18 juil.</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte-ot-tel moyenne">
|
||||||
|
<div class="type-t">OT-2026-0336 · Préventif <span class="st ouvert">Ouvert</span> <span class="pastille-file">2 en file</span></div>
|
||||||
|
<b>Contrôle mensuel — juillet</b>
|
||||||
|
<div class="ou">Asc. A2 — Résidence Al Manar · grille 7/12</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte-ot-tel basse">
|
||||||
|
<div class="type-t">OT-2026-0329 · Travaux <span class="st attente">En attente</span></div>
|
||||||
|
<b>Remplacement coulisseaux</b>
|
||||||
|
<div class="ou">Asc. C1 — Anfa Place · pièce commandée (BC-2026-0024)</div>
|
||||||
|
</div>
|
||||||
|
<nav class="tabbar">
|
||||||
|
<a class="actif" href="#"><span class="pic">☰</span>Ma journée</a>
|
||||||
|
<a href="#"><span class="pic">▣</span>Scanner</a>
|
||||||
|
<a href="#"><span class="pic">✓</span>Préventif</a>
|
||||||
|
<a href="#"><span class="pic">⇅</span>Synchro<span class="badge-tab">3</span></a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ 2 · FICHE OT ═══ -->
|
||||||
|
<p class="note-ecran" data-note="fiche-ot" hidden><b>Intention :</b> la même machine à états que le web
|
||||||
|
(R2) — un seul <b>bouton principal</b> selon l'état, jamais de liste de transitions. Consommer une pièce
|
||||||
|
et saisir du temps reprennent les règles R3 (prix et taux <b>figés</b> annoncés avant le geste). Les photos
|
||||||
|
partent en file comme le reste. Aucune saisie n'exige le réseau.</p>
|
||||||
|
<section class="ecran" id="fiche-ot">
|
||||||
|
<div class="tels">
|
||||||
|
<div class="tel">
|
||||||
|
<div class="encoche"><i></i></div>
|
||||||
|
<div class="legende">OT en cours — actions terrain</div>
|
||||||
|
<div class="ecran-tel">
|
||||||
|
<div class="entete-tel">
|
||||||
|
<span class="flamme">♦</span><b>‹ OT-2026-0341</b>
|
||||||
|
<span class="fin"><span class="sync">Synchro à jour</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="titre-tel">Bruit anormal en gaine <span class="st encours">En cours</span></div>
|
||||||
|
<div class="carte-tel">
|
||||||
|
<h4>Intervention</h4>
|
||||||
|
<div class="ligne-info"><span>Équipement</span><b>Asc. A1 — Résidence Al Manar</b></div>
|
||||||
|
<div class="ligne-info"><span>Priorité</span><b style="color:var(--prio-haute)">Haute</b></div>
|
||||||
|
<div class="ligne-info"><span>Échéance</span><b>18 juillet</b></div>
|
||||||
|
<div class="ligne-info"><span>Demande liée</span><b>DEM-2026-0107 · Karim D.</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="carte-tel">
|
||||||
|
<h4>Pièces & main-d'œuvre — 505 MAD</h4>
|
||||||
|
<div class="ligne-info"><span>Coulisseau 16 mm × 2</span><b class="num">240</b></div>
|
||||||
|
<div class="ligne-info"><span>Graisse guide × 1</span><b class="num">85</b></div>
|
||||||
|
<div class="ligne-info"><span>Ahmed · 1 h 30 × 120/h</span><b class="num">180</b></div>
|
||||||
|
<div class="paire-boutons">
|
||||||
|
<button class="btn-tel contour">+ Pièce</button>
|
||||||
|
<button class="btn-tel contour">+ Temps</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte-tel">
|
||||||
|
<h4>Photos</h4>
|
||||||
|
<div class="photos-tel">
|
||||||
|
<span class="photo-tel">🖼</span>
|
||||||
|
<span class="photo-tel">🖼<span class="file-chip">en file</span></span>
|
||||||
|
<span class="photo-tel" style="color:var(--primaire);font-size:23px">+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-tel vert">Clôturer l'intervention</button>
|
||||||
|
<div class="garde">Bilan incomplet : « État des portes », « Action réalisée », « Élément concerné » — la clôture ouvrira le bilan.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ 3 · CLÔTURE TERRAIN ═══ -->
|
||||||
|
<p class="note-ecran" data-note="cloture" hidden><b>Intention :</b> le bilan codé R2, plein écran et au
|
||||||
|
pouce : 3 champs requis (<em>*</em>), la garde reste <b>visible et bloquante</b> tant qu'ils manquent —
|
||||||
|
identique au web, mêmes référentiels administrables. Hors-ligne, « Clôturer » met la clôture <b>en
|
||||||
|
file</b> : l'OT passe localement à « Terminé (en file) » et se confirme à la synchro.</p>
|
||||||
|
<section class="ecran" id="cloture">
|
||||||
|
<div class="tels">
|
||||||
|
<div class="tel">
|
||||||
|
<div class="encoche"><i></i></div>
|
||||||
|
<div class="legende">Bilan incomplet — garde visible</div>
|
||||||
|
<div class="ecran-tel">
|
||||||
|
<div class="entete-tel"><span class="flamme">♦</span><b>‹ Clôturer OT-2026-0341</b></div>
|
||||||
|
<div class="carte-tel">
|
||||||
|
<h4>Bilan d'intervention — requis pour clôturer</h4>
|
||||||
|
<div class="grille-2-tel">
|
||||||
|
<div class="champ-tel"><label>État des portes <em>*</em></label><span class="select-tel">Fonctionnelles ▾</span></div>
|
||||||
|
<div class="champ-tel"><label>Position cabine</label><span class="select-tel vide">Sélectionner ▾</span></div>
|
||||||
|
<div class="champ-tel"><label>Anomalie constatée</label><span class="select-tel">Frottement guides ▾</span></div>
|
||||||
|
<div class="champ-tel"><label>Cause extérieure</label><span class="select-tel vide">Sélectionner ▾</span></div>
|
||||||
|
<div class="champ-tel"><label>Action réalisée <em>*</em></label><span class="select-tel">Remplacement pièce ▾</span></div>
|
||||||
|
<div class="champ-tel"><label>Élément concerné <em>*</em></label><span class="select-tel vide">Sélectionner ▾</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="garde">⚠ « Élément concerné » manque — la clôture restera bloquée (même garde que le web).</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte-tel">
|
||||||
|
<h4>Checklist liée — 12/12 ✓</h4>
|
||||||
|
<div class="ligne-info"><span>Grille de juillet cochée sur place</span><b style="color:var(--succes)">complète</b></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-tel gris">Clôturer (bilan incomplet)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tel">
|
||||||
|
<div class="encoche"><i></i></div>
|
||||||
|
<div class="legende">Complet, hors-ligne — clôture en file</div>
|
||||||
|
<div class="ecran-tel">
|
||||||
|
<div class="entete-tel"><span class="flamme">♦</span><b>‹ Clôturer OT-2026-0341</b>
|
||||||
|
<span class="fin"><span class="sync off">Hors-ligne</span></span></div>
|
||||||
|
<div class="carte-tel">
|
||||||
|
<h4>Bilan d'intervention — complet</h4>
|
||||||
|
<div class="grille-2-tel">
|
||||||
|
<div class="champ-tel"><label>État des portes <em>*</em></label><span class="select-tel">Fonctionnelles ▾</span></div>
|
||||||
|
<div class="champ-tel"><label>Position cabine</label><span class="select-tel">À niveau ▾</span></div>
|
||||||
|
<div class="champ-tel"><label>Anomalie constatée</label><span class="select-tel">Frottement guides ▾</span></div>
|
||||||
|
<div class="champ-tel"><label>Cause extérieure</label><span class="select-tel">Aucune ▾</span></div>
|
||||||
|
<div class="champ-tel"><label>Action réalisée <em>*</em></label><span class="select-tel">Remplacement pièce ▾</span></div>
|
||||||
|
<div class="champ-tel"><label>Élément concerné <em>*</em></label><span class="select-tel">Guides ▾</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-tel vert">Clôturer — partira à la synchro</button>
|
||||||
|
<div class="bandeau-off">La clôture est enregistrée sur le téléphone. Elle sera rejouée telle quelle au retour du réseau.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ 4 · SCAN QR ═══ -->
|
||||||
|
<p class="note-ecran" data-note="scan" hidden><b>Intention :</b> le scanner lit le <b>QR des étiquettes
|
||||||
|
A6</b> posées en R1 (la référence de l'appareil) et ouvre la fiche — y compris <b>hors-ligne</b>, sur le
|
||||||
|
parc déjà synchronisé. En dessous de l'épaule : saisie manuelle de la référence. Aucune géolocalisation
|
||||||
|
en R4 (backlog v2, loi 09-08 : minimisation).</p>
|
||||||
|
<section class="ecran" id="scan">
|
||||||
|
<div class="tels">
|
||||||
|
<div class="tel">
|
||||||
|
<div class="encoche"><i></i></div>
|
||||||
|
<div class="legende">Scanner — QR de l'étiquette A6</div>
|
||||||
|
<div class="ecran-tel">
|
||||||
|
<div class="entete-tel"><span class="flamme">♦</span><b>Scanner</b>
|
||||||
|
<span class="fin"><span class="sync off">Hors-ligne</span></span></div>
|
||||||
|
<div class="viseur">
|
||||||
|
<span class="cadre-qr"></span>
|
||||||
|
<p>Visez le QR de l'étiquette de cabine</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-tel contour">Saisir la référence (A1, B2…)</button>
|
||||||
|
<nav class="tabbar">
|
||||||
|
<a href="#"><span class="pic">☰</span>Ma journée</a>
|
||||||
|
<a class="actif" href="#"><span class="pic">▣</span>Scanner</a>
|
||||||
|
<a href="#"><span class="pic">✓</span>Préventif</a>
|
||||||
|
<a href="#"><span class="pic">⇅</span>Synchro<span class="badge-tab">3</span></a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ 5 · FICHE ASCENSEUR ═══ -->
|
||||||
|
<p class="note-ecran" data-note="ascenseur" hidden><b>Intention :</b> après le scan, tout ce qu'Ahmed
|
||||||
|
doit savoir <b>devant la machine</b> : identité, statut, organes, historique des interventions (bilans
|
||||||
|
codés lisibles). Consultation seule : la création d'OT reste au dispatcher (web) et le signalement au
|
||||||
|
portail QR public (R2) — le mobile n'ajoute pas de 3ᵉ canal.</p>
|
||||||
|
<section class="ecran" id="ascenseur">
|
||||||
|
<div class="tels">
|
||||||
|
<div class="tel">
|
||||||
|
<div class="encoche"><i></i></div>
|
||||||
|
<div class="legende">Résultat du scan — Asc. A1</div>
|
||||||
|
<div class="ecran-tel">
|
||||||
|
<div class="entete-tel"><span class="flamme">♦</span><b>‹ Asc. A1</b>
|
||||||
|
<span class="fin"><span class="st termine">En service</span></span></div>
|
||||||
|
<div class="titre-tel">Otis Gen2 — Résidence Al Manar</div>
|
||||||
|
<div class="carte-tel">
|
||||||
|
<h4>Identité</h4>
|
||||||
|
<div class="ligne-info"><span>Emplacement</span><b>Hall principal</b></div>
|
||||||
|
<div class="ligne-info"><span>Mise en service</span><b>mars 2019</b></div>
|
||||||
|
<div class="ligne-info"><span>Sous contrat</span><b style="color:var(--succes)">Oui — préventif mensuel</b></div>
|
||||||
|
<div class="ligne-info"><span>Gardien</span><b>M. Alami · 06 12 34 56 78</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="carte-tel">
|
||||||
|
<h4>Interventions récentes</h4>
|
||||||
|
<div class="hist-item"><span class="ref">OT-0341</span> Bruit anormal — guides <span class="quand">en cours</span></div>
|
||||||
|
<div class="hist-item"><span class="ref">OT-0329</span> Portes — contact remplacé <span class="quand">2 juil.</span></div>
|
||||||
|
<div class="hist-item"><span class="ref">OT-0318</span> Préventif juin — 12/12 ✓ <span class="quand">18 juin</span></div>
|
||||||
|
<div class="hist-item"><span class="ref">OT-0303</span> Armoire cde — fusible <span class="quand">30 mai</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="carte-tel">
|
||||||
|
<h4>Organes suivis</h4>
|
||||||
|
<div class="ligne-info"><span>Treuil ZAA20</span><b>—</b></div>
|
||||||
|
<div class="ligne-info"><span>Portes cabine S3300</span><b>2 pannes / 12 mois</b></div>
|
||||||
|
<div class="ligne-info"><span>Armoire de commande</span><b>1 panne / 12 mois</b></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-tel">Voir mes OT sur cet appareil (2)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ 6 · CHECKLIST DU MOIS ═══ -->
|
||||||
|
<p class="note-ecran" data-note="checklist" hidden><b>Intention :</b> la grille préventive R2, cochable
|
||||||
|
<b>au pouce et hors-ligne</b> — chaque coche part en file individuellement (pas de « tout ou rien » :
|
||||||
|
7 coches faites = 7 coches sauvées, même si le téléphone s'éteint). La progression et le statut du mois
|
||||||
|
sont ceux du serveur à la dernière synchro, assumés comme tels.</p>
|
||||||
|
<section class="ecran" id="checklist">
|
||||||
|
<div class="tels">
|
||||||
|
<div class="tel">
|
||||||
|
<div class="encoche"><i></i></div>
|
||||||
|
<div class="legende">Grille de juillet — hors-ligne, 7/12</div>
|
||||||
|
<div class="ecran-tel">
|
||||||
|
<div class="entete-tel"><span class="flamme">♦</span><b>‹ Préventif — juillet</b>
|
||||||
|
<span class="fin"><span class="sync off">Hors-ligne</span></span></div>
|
||||||
|
<div class="titre-tel">Asc. A2 — grille mensuelle <span class="filet num">7/12</span></div>
|
||||||
|
<div class="progress"><i></i></div>
|
||||||
|
<div class="check-item fait"><span class="case"></span>Verrouillage portes palières</div>
|
||||||
|
<div class="check-item fait"><span class="case"></span>Contacts de porte cabine</div>
|
||||||
|
<div class="check-item fait"><span class="case"></span>Niveau d'huile du treuil</div>
|
||||||
|
<div class="check-item fait"><span class="case"></span>Jeu des coulisseaux <span class="file-chip">en file</span></div>
|
||||||
|
<div class="check-item fait"><span class="case"></span>Éclairage cabine & secours <span class="file-chip">en file</span></div>
|
||||||
|
<div class="check-item"><span class="case"></span>Parachute — essai statique</div>
|
||||||
|
<div class="check-item"><span class="case"></span>Fins de course haut/bas</div>
|
||||||
|
<div class="check-item"><span class="case"></span>Téléalarme — essai d'appel</div>
|
||||||
|
<div class="bandeau-off">2 coches en file — la grille se recale à la synchro.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ 7 · SYNCHRO & CONFLITS ═══ -->
|
||||||
|
<p class="note-ecran" data-note="synchro" hidden><b>Intention :</b> la file est <b>visible et honnête</b> :
|
||||||
|
chaque saisie hors-ligne y est listée, rejouée dans l'ordre au retour du réseau. Conflit (verrou
|
||||||
|
optimiste : l'OT a changé côté serveur depuis la lecture) → la file <b>s'arrête sur l'élément</b>, montre
|
||||||
|
qui a modifié quoi et quand, et l'humain tranche — rien n'est écrasé ni perdu en silence. C'est l'écran
|
||||||
|
de la recette type « mode avion en sous-sol ».</p>
|
||||||
|
<section class="ecran" id="synchro">
|
||||||
|
<div class="tels">
|
||||||
|
<div class="tel">
|
||||||
|
<div class="encoche"><i></i></div>
|
||||||
|
<div class="legende">Retour réseau — file en cours</div>
|
||||||
|
<div class="ecran-tel">
|
||||||
|
<div class="entete-tel"><span class="flamme">♦</span><b>Synchro</b>
|
||||||
|
<span class="fin"><span class="sync">Envoi… 2/4</span></span></div>
|
||||||
|
<div class="titre-tel">File d'attente <span class="filet">rejouée dans l'ordre</span></div>
|
||||||
|
<div class="file-item"><span class="ico">✓</span>
|
||||||
|
<span class="quoi"><b>Coche « Jeu des coulisseaux »</b><span>OT-2026-0336 · 14 h 21</span></span>
|
||||||
|
<span class="etat fait">envoyée</span></div>
|
||||||
|
<div class="file-item"><span class="ico">✓</span>
|
||||||
|
<span class="quoi"><b>Coche « Éclairage cabine »</b><span>OT-2026-0336 · 14 h 24</span></span>
|
||||||
|
<span class="etat fait">envoyée</span></div>
|
||||||
|
<div class="file-item"><span class="ico">🖼</span>
|
||||||
|
<span class="quoi"><b>Photo — guides (1,8 Mo)</b><span>OT-2026-0341 · 14 h 32</span></span>
|
||||||
|
<span class="etat envoi">envoi…</span></div>
|
||||||
|
<div class="file-item"><span class="ico">🏁</span>
|
||||||
|
<span class="quoi"><b>Clôture + bilan codé</b><span>OT-2026-0341 · 14 h 40</span></span>
|
||||||
|
<span class="etat attente">en attente</span></div>
|
||||||
|
<nav class="tabbar">
|
||||||
|
<a href="#"><span class="pic">☰</span>Ma journée</a>
|
||||||
|
<a href="#"><span class="pic">▣</span>Scanner</a>
|
||||||
|
<a href="#"><span class="pic">✓</span>Préventif</a>
|
||||||
|
<a class="actif" href="#"><span class="pic">⇅</span>Synchro<span class="badge-tab">2</span></a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tel">
|
||||||
|
<div class="encoche"><i></i></div>
|
||||||
|
<div class="legende">Conflit — l'humain tranche</div>
|
||||||
|
<div class="ecran-tel">
|
||||||
|
<div class="entete-tel"><span class="flamme">♦</span><b>Synchro</b>
|
||||||
|
<span class="fin"><span class="sync off">1 conflit</span></span></div>
|
||||||
|
<div class="titre-tel">File d'attente <span class="filet">arrêtée sur le conflit</span></div>
|
||||||
|
<div class="carte-conflit">
|
||||||
|
<h4>⚠ Conflit sur OT-2026-0341</h4>
|
||||||
|
<p><b>Salma Idrissi a modifié cet OT à 14 h 38</b> (réassigné + commentaire) pendant que vous étiez hors-ligne. Votre clôture de 14 h 40 n'a <b>pas</b> été appliquée.</p>
|
||||||
|
<small>Votre saisie est conservée telle quelle dans la file — rien n'est perdu.</small>
|
||||||
|
<button class="btn-tel" style="padding:10px;font-size:13px">Revoir l'OT à jour puis rejouer</button>
|
||||||
|
<button class="btn-tel contour" style="padding:10px;font-size:13px">Abandonner ma clôture</button>
|
||||||
|
</div>
|
||||||
|
<div class="file-item"><span class="ico">🏁</span>
|
||||||
|
<span class="quoi"><b>Clôture + bilan codé</b><span>OT-2026-0341 · 14 h 40</span></span>
|
||||||
|
<span class="etat conflit">conflit</span></div>
|
||||||
|
<div class="file-item"><span class="ico">🖼</span>
|
||||||
|
<span class="quoi"><b>Photo — guides (1,8 Mo)</b><span>OT-2026-0341 · 14 h 32</span></span>
|
||||||
|
<span class="etat fait">envoyée</span></div>
|
||||||
|
<nav class="tabbar">
|
||||||
|
<a href="#"><span class="pic">☰</span>Ma journée</a>
|
||||||
|
<a href="#"><span class="pic">▣</span>Scanner</a>
|
||||||
|
<a href="#"><span class="pic">✓</span>Préventif</a>
|
||||||
|
<a class="actif" href="#"><span class="pic">⇅</span>Synchro<span class="badge-tab rouge">1</span></a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var onglets=document.querySelectorAll('.revue .onglets button');
|
||||||
|
var ecrans=document.querySelectorAll('.ecran');
|
||||||
|
var notes=document.querySelectorAll('.note-ecran');
|
||||||
|
onglets.forEach(function(b){b.addEventListener('click',function(){
|
||||||
|
onglets.forEach(function(x){x.setAttribute('aria-selected','false')});
|
||||||
|
b.setAttribute('aria-selected','true');
|
||||||
|
var cible=b.dataset.cible;
|
||||||
|
ecrans.forEach(function(e){e.classList.toggle('actif',e.id===cible)});
|
||||||
|
notes.forEach(function(n){n.hidden=(n.dataset.note!==cible)});
|
||||||
|
window.scrollTo({top:0});
|
||||||
|
})});
|
||||||
|
var ordre=['auto','light','dark'];var i=0;
|
||||||
|
var bt=document.getElementById('btn-theme');
|
||||||
|
bt.addEventListener('click',function(){
|
||||||
|
i=(i+1)%3;var m=ordre[i];
|
||||||
|
if(m==='auto'){document.documentElement.removeAttribute('data-theme')}
|
||||||
|
else{document.documentElement.setAttribute('data-theme',m)}
|
||||||
|
bt.textContent='Thème : '+(m==='auto'?'auto':(m==='light'?'clair':'sombre'));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
53
docs/03-architecture/adr/ADR-003-securite-routage-mobile.md
Normal file
53
docs/03-architecture/adr/ADR-003-securite-routage-mobile.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# ADR-003 — Sécurité et protocole de routage de l'app mobile (R4)
|
||||||
|
|
||||||
|
- **Statut** : acceptée (R4.3, 17 juillet 2026)
|
||||||
|
- **Contexte** : l'app technicien est offline-first (D1) — elle garde des données métier
|
||||||
|
et des saisies en attente **sur l'appareil**. Il faut dire précisément ce qui vit où,
|
||||||
|
ce qui le protège, et comment on navigue (deep links compris). Demande du référent
|
||||||
|
pendant la construction de R4.3.
|
||||||
|
|
||||||
|
## Sécurité — ce qui vit sur le téléphone, et ce qui le protège
|
||||||
|
|
||||||
|
| Donnée | Où | Protection | Purge |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Jeton JWT | **SecureStore** (trousseau iOS / Keystore Android) | chiffré par l'OS, jamais dans AsyncStorage | à la déconnexion |
|
||||||
|
| Cache de lecture (OT, parc, référentiels) | AsyncStorage (`siop.cache`, persister TanStack, 7 j max) | sandbox applicative de l'OS | à la déconnexion (loi 09-08 — minimisation) |
|
||||||
|
| File d'écriture (D1) | AsyncStorage (`siop.file.v1`) | sandbox applicative | à la déconnexion — **jamais** un compte ne rejoue les saisies d'un autre |
|
||||||
|
| Photos en attente (D5) | fichier compressé (~1 600 px) référencé par la file | sandbox applicative | sortent de la file à l'envoi |
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
1. **L'API reste la seule autorité** : guard JWT global fermé par défaut, matrice de
|
||||||
|
permissions, invariant « voir autre », garde de clôture, verrou optimiste (D2 —
|
||||||
|
`baseUpdatedAt`, 409 contextualisé). Le mobile n'a AUCUNE logique de permission à lui :
|
||||||
|
ce qu'il affiche vient d'une réponse déjà filtrée ; ce qu'il écrit est revalidé serveur.
|
||||||
|
2. Sur **web** (vérifications de dev seulement), SecureStore n'existe pas : repli
|
||||||
|
AsyncStorage assumé. CORS est **opt-in** (`CORS_ORIGINS`, vide par défaut) — les apps
|
||||||
|
natives n'envoient pas d'Origin, le web de prod reste derrière son proxy même-origine.
|
||||||
|
3. **Ni géolocalisation ni audio en R4** (décision D5, loi 09-08) ; la caméra ne sert
|
||||||
|
qu'à lire les étiquettes du parc (scan local, D4) et aux photos d'intervention.
|
||||||
|
4. La déconnexion purge **tout** ce qui précède (`useLogout`) ; la file survit au
|
||||||
|
redémarrage mais pas au changement de session.
|
||||||
|
|
||||||
|
## Protocole de routage (expo-router)
|
||||||
|
|
||||||
|
- **Arborescence** : `/connexion` (publique) ; `/` = aiguillage de session ;
|
||||||
|
`(tabs)/journee|scanner|preventif|synchro` (coquille tabbar) ; fiches empilées
|
||||||
|
`/ascenseur/[id]`, `/ot/[id]`, `/ot/[id]/cloture`, `/ot/[id]/grille`.
|
||||||
|
- **Garde de session** : `/` redirige vers `/connexion` sans session, vers
|
||||||
|
`(tabs)/journee` sinon. Les écrans profonds ne « fuient » rien sans jeton :
|
||||||
|
leurs requêtes reçoivent 401 et l'écran reste vide — la donnée est protégée par
|
||||||
|
l'API, pas par l'obscurité du routage.
|
||||||
|
- **Deep links** : scheme **`siop://`** (app.json). Le scan QR n'ouvre PAS l'URL du
|
||||||
|
portail : il en **extrait la référence** (`analyseScan`) et résout localement
|
||||||
|
(D4) — un QR étranger est refusé avec un message, jamais suivi aveuglément.
|
||||||
|
- **Hors-ligne** : la navigation reste entière (cache + file) ; seul le contenu
|
||||||
|
jamais synchronisé affiche son état honnêtement.
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- Recette sécurité mobile : changer de compte démo sur le même appareil → file vide,
|
||||||
|
cache vide, aucune trace de la session précédente.
|
||||||
|
- À durcir plus tard si exigence client : chiffrement applicatif de la file
|
||||||
|
(`expo-secure-store` ne convient pas aux volumes ; envisager SQLCipher/MMKV chiffré),
|
||||||
|
et épinglage TLS.
|
||||||
@@ -4,6 +4,90 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-17 — Pr. Daaif (+ Claude) — R4 CLOSE : tag `release/r4` (recette téléphone reportée)
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- Tag annoté **`release/r4`** posé et poussé (CI verte sur `c8b3c17` : 74 tests API, 17 jest-expo, 14 Playwright).
|
||||||
|
|
||||||
|
**Décisions**
|
||||||
|
|
||||||
|
- **Décision du référent : la recette sur téléphone (Expo Go — vrai mode avion, scan caméra) est REPORTÉE**, il y accédera plus tard avec un appareil. Le tag est posé sur la foi de la recette « mode avion » rejouée 13/13 en Expo web piloté (toute la mécanique D1/D2 vérifiée, capteurs exclus). **La validation terrain reste due avant toute mise en production client de l'app mobile** — notée comme reste de release.
|
||||||
|
|
||||||
|
**Prochaine étape** : ouverture **R5 — IA** (design d'abord : maquettes assistant RAG sourcé + suggestion de codes de bilan, décisions à acter). Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-17 — Pr. Daaif (+ Claude) — R4.3 : file d'écriture, verrou optimiste, Synchro & conflits
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **Verrou optimiste côté serveur (D2)** : `updatedAt` exposé au détail OT ; `baseUpdatedAt` optionnel sur transition/coche/bilan → **409 « Conflit de version » contextualisé** (qui, quand — depuis le dernier événement). Toute écriture « secondaire » (coche, bilan, commentaire, conso, MO) fait désormais avancer la version, sinon le verrou serait aveugle. Sans version fournie, comportement web inchangé. 4 tests e2e dédiés (74 au total).
|
||||||
|
- **File d'écriture mobile (D1)** : store persisté AsyncStorage (survit au redémarrage, un ENVOI interrompu redevient rejouable), rejeu **dans l'ordre** ; succès → sortie de file **et propagation de la version fraîche** aux saisies restantes du même OT (nos propres écritures ne se conflictent pas entre elles — un écart étranger reste détecté) ; coupure → tout reste en attente ; refus → CONFLIT et la file s'arrête là. Transitions, coches, bilan et **photos (D5** : compressées ~1 600 px, expo-image-picker/manipulator**)** passent par la file, avec patch optimiste du cache (l'OT affiche « Terminé » localement, chips « en file » partout).
|
||||||
|
- **Écran Synchro & conflits** (écran 7) : file visible et horodatée, badge tabbar (ambre → rouge si conflit), carte de conflit avec le message de l'API et les trois choix — voir l'OT, **rejouer sur la version à jour**, abandonner. Préchargement du parc et des référentiels à l'entrée (trou D1 débusqué par la vérif : le bilan hors-ligne n'avait pas ses vocabulaires).
|
||||||
|
- **Sécurité & routage (demande du référent) : ADR-003** — qui vit où sur l'appareil et ce qui le protège (jeton en trousseau, cache/file en sandbox, purge complète à la déconnexion), l'API seule autorité, deep links `siop://` jamais suivis aveuglément (le scan extrait et résout localement). **Correctif réel au passage : la file et le cache persisté n'étaient pas purgés au logout** — un autre compte sur le même téléphone aurait pu rejouer les saisies du précédent.
|
||||||
|
- **Recette « mode avion » rejouée en Expo web piloté : 13/13** — OT ouvert, avion, Démarrer + bilan + clôture hors-ligne (état local immédiat, 3 en file), Salma modifie l'OT pendant ce temps, retour réseau → rejeu auto → **conflit tranché par l'humain** → file vide → serveur : Terminé avec bilan. 17 tests jest-expo (5 sur la file), zéro erreur console inattendue.
|
||||||
|
|
||||||
|
**Décisions**
|
||||||
|
|
||||||
|
- La recette officielle « mode avion en sous-sol » reste à rejouer **sur téléphone** (Expo Go) avec le référent — le web a validé toute la mécanique, pas les capteurs ni le vrai mode avion.
|
||||||
|
- Durcissements notés à l'ADR-003 : chiffrement applicatif de la file et épinglage TLS si exigence client.
|
||||||
|
|
||||||
|
**Prochaine étape** : recette R4 sur appareil avec le référent → tag `release/r4` → R5 IA (design d'abord). Redéploiement Dokploy de `release/r3` toujours en attente côté serveur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-17 — Pr. Daaif (+ Claude) — R4.2 : scan QR, fiches terrain, grille cochable
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **Scanner réel** (expo-camera, QR seulement) : analyse du code testée (`analyseScan` — URL portail `…/q/REF` de l'étiquette A6 quel que soit le domaine, ou référence tapée ; les QR étrangers sont refusés proprement, jamais d'écran blanc). **Résolution D4 : le parc en cache d'abord** (fonctionne en sous-sol), rafraîchissement seulement si le réseau est là ; référence inconnue → message honnête. Repli saisie manuelle (seul chemin sur web, assumé).
|
||||||
|
- **Fiche ascenseur** (consultation, D3) : identité, organes, interventions **visibles par le rôle** (invariant « voir autre »), raccourci vers l'OT en cours. **Fiche OT** : un bouton principal selon la machine à états, pièces & main-d'œuvre aux coûts figés, garde de clôture alimentée par les `closureBlockers` de l'API (une seule source de vérité). **Clôture terrain** : bilan codé 6 champs (sélecteur plein écran au pouce — RN n'a pas de `<select>`), garde visible, clôture en ligne. **Préventif** : mes grilles → checklist cochable, appui long = N/A, coche individuelle à l'API.
|
||||||
|
- En R4.2 les écritures restent **en ligne** (bandeaux explicites hors-ligne) — la file générale et le verrou optimiste sont le cœur de R4.3, comme prévu.
|
||||||
|
- Vérifié en Expo web piloté : **12/12** — scan A1 → fiche → OT-0341 (505 MAD, garde), référence inconnue, coche/décoche (état restitué), et un OT créé par l'API : Démarrer → clôture bloquée bilan incomplet → 3 champs remplis → garde éteinte → **Terminé** ; zéro erreur console ; données de test purgées. 12 tests jest-expo (scan, progression, tri, tokens).
|
||||||
|
|
||||||
|
**Décisions**
|
||||||
|
|
||||||
|
- Leçon accessibilité RN web : `accessibilityState.checked` ne produit PAS `aria-checked` — utiliser la prop `aria-checked` (mieux pour les lecteurs d'écran, et testable).
|
||||||
|
- La caméra ne se recette pas sur web : scan réel à valider dans Expo Go (le repli manuel couvre le parcours en attendant).
|
||||||
|
|
||||||
|
**Prochaine étape** : R4.3 — file d'écriture persistée rejouée dans l'ordre, verrou optimiste tranché par l'humain (D2), écran Synchro & conflits, photos en file (D5) → recette « mode avion » sur téléphone. Redéploiement Dokploy de `release/r3` toujours en attente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-17 — Pr. Daaif (+ Claude) — R4.1 : socle mobile Expo (app du technicien)
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **`apps/mobile` posé** (Expo SDK 57, TypeScript strict, pnpm workspace, `@siop/shared` consommé) : connexion e-mail/mdp + **sélecteur démo ADR-002** (n'existe que si l'API l'expose), coquille **tabbar** de la maquette (Scanner/Préventif/Synchro visibles mais marqués R4.2/R4.3 — même patron que la sidebar web), **Ma journée** : OT triés priorité puis échéance (fonction pure testée), urgence « personne bloquée » en tête, strie de priorité, pastille de synchro permanente.
|
||||||
|
- **D1 en actes (lecture)** : cache TanStack **persisté** dans AsyncStorage (7 jours), NetInfo → `onlineManager` + bandeau hors-ligne horodaté. Jeton en SecureStore. Client typé régénéré depuis `docs/openapi.json` (règle d'or). Thème : tokens light/dark répliqués (testés), Manrope embarquée.
|
||||||
|
- **API : `CORS_ORIGINS`** (env, vide par défaut = pas de CORS) — nécessaire à Expo **web**/debug seulement ; le web de prod reste derrière le proxy nginx, les apps natives n'ont pas d'Origin.
|
||||||
|
- Vérifié en navigateur réel (Expo web + Playwright) : **10/10** — connexion démo Ahmed → Ma journée (ses OT seulement : le scoping « voir autre » joue), bascule hors-ligne (bandeau + liste servie du cache) et retour, onglets à venir honnêtes ; zéro erreur console. 6 tests jest-expo, lint racine étendu (react-hooks sur mobile), **job CI `mobile`** ajouté (deploy dépend de lui).
|
||||||
|
|
||||||
|
**Décisions**
|
||||||
|
|
||||||
|
- La vraie recette hors-ligne (mode avion, redémarrage, file d'écriture) se fera **sur téléphone** — Expo web ne prouve que le chemin de code NetInfo→UI (dispatch `navigator.connection.change` observé).
|
||||||
|
- Leçon Playwright : `setOffline` ne déclenche pas les événements réseau du navigateur — NetInfo web écoute `navigator.connection` sur Chromium.
|
||||||
|
|
||||||
|
**Prochaine étape** : R4.2 — scan QR (expo-camera) + fiche ascenseur + fiche OT + checklist cochable ; puis R4.3 file d'écriture + verrou optimiste (D2). Redéploiement Dokploy de `release/r3` toujours en attente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-17 — Pr. Daaif (+ Claude) — R4 ouverte : maquettes mobile (design d'abord)
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **`maquette-r4.html`** (docs/02-design/maquettes) : 7 écrans de l'app technicien — Ma journée (en ligne ET hors-ligne côte à côte), Fiche OT, Clôture terrain (bilan codé R2, garde visible ; variante « en file » hors-ligne), Scan QR (étiquettes A6 de R1, hors-ligne inclus), Fiche ascenseur (consultation devant la machine), Checklist du mois (coches individuelles en file), **Synchro & conflits** (file visible, conflit de verrou optimiste où l'humain tranche). Tokens répliqués, bi-thème, gabarit téléphone repris de la maquette R3, données du parc seedé. Vérifiée en navigateur (7 onglets + bascule sombre, zéro erreur console).
|
||||||
|
- Artefact de validation publié pour le référent (7 écrans + preuve bi-thème + **5 décisions à acter** : D1 offline-first lecture locale/écriture en file, D2 verrou optimiste tranché par l'humain, D3 périmètre fermé « app du technicien » sans création d'OT mobile, D4 scan local des étiquettes R1, D5 photos compressées en file — pas d'audio ni de géolocalisation en R4, loi 09-08).
|
||||||
|
|
||||||
|
**Décisions**
|
||||||
|
|
||||||
|
- **→ Levé le 17/07/2026 : maquettes R4 et les 5 décisions (D1-D5) VALIDÉES par le référent.** Lancement R4.1 (socle mobile Expo).
|
||||||
|
|
||||||
|
**Prochaine étape** : R4.1 — socle Expo : coquille tabbar, connexion + sélecteur démo (ADR-002), client typé du contrat, « Ma journée » en lecture avec cache hors-ligne. En parallèle, redéploiement Dokploy de `release/r3` toujours à faire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-17 — Pr. Daaif (+ Claude) — R3 CLOSE : tag `release/r3`
|
## 2026-07-17 — Pr. Daaif (+ Claude) — R3 CLOSE : tag `release/r3`
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
@@ -6698,6 +6698,11 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -6725,7 +6730,8 @@
|
|||||||
"report",
|
"report",
|
||||||
"costs",
|
"costs",
|
||||||
"allowedTransitions",
|
"allowedTransitions",
|
||||||
"closureBlockers"
|
"closureBlockers",
|
||||||
|
"updatedAt"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
@@ -7528,6 +7534,11 @@
|
|||||||
"comment": {
|
"comment": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 500
|
"maxLength": 500
|
||||||
|
},
|
||||||
|
"baseUpdatedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -7655,6 +7666,11 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"baseUpdatedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -7741,6 +7757,11 @@
|
|||||||
"DONE",
|
"DONE",
|
||||||
"NA"
|
"NA"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"baseUpdatedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default tseslint.config(
|
|||||||
rules: { 'no-restricted-imports': 'off' },
|
rules: { 'no-restricted-imports': 'off' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['apps/web/**/*.{ts,tsx}'],
|
files: ['apps/web/**/*.{ts,tsx}', 'apps/mobile/**/*.{ts,tsx}'],
|
||||||
plugins: { 'react-hooks': reactHooks },
|
plugins: { 'react-hooks': reactHooks },
|
||||||
rules: {
|
rules: {
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export const WorkOrderDetailSchema = WorkOrderSummarySchema.extend({
|
|||||||
allowedTransitions: z.array(z.enum(WORK_ORDER_STATUSES)),
|
allowedTransitions: z.array(z.enum(WORK_ORDER_STATUSES)),
|
||||||
/** Ce qui bloque la clôture (vide = clôturable) — messages métier. */
|
/** Ce qui bloque la clôture (vide = clôturable) — messages métier. */
|
||||||
closureBlockers: z.array(z.string()),
|
closureBlockers: z.array(z.string()),
|
||||||
|
/** Version pour le verrou optimiste (D2 mobile) : la file d'écriture la
|
||||||
|
* capture à la lecture et la renvoie avec chaque saisie. */
|
||||||
|
updatedAt: z.iso.datetime(),
|
||||||
});
|
});
|
||||||
export type WorkOrderDetail = z.infer<typeof WorkOrderDetailSchema>;
|
export type WorkOrderDetail = z.infer<typeof WorkOrderDetailSchema>;
|
||||||
|
|
||||||
@@ -104,6 +107,8 @@ export type WorkOrderCreate = z.infer<typeof WorkOrderCreateSchema>;
|
|||||||
export const TransitionRequestSchema = z.object({
|
export const TransitionRequestSchema = z.object({
|
||||||
to: z.enum(WORK_ORDER_STATUSES),
|
to: z.enum(WORK_ORDER_STATUSES),
|
||||||
comment: z.string().max(500).optional(),
|
comment: z.string().max(500).optional(),
|
||||||
|
/** Verrou optimiste (D2) : updatedAt lu — 409 si l'OT a changé depuis. */
|
||||||
|
baseUpdatedAt: z.iso.datetime().optional(),
|
||||||
});
|
});
|
||||||
export type TransitionRequest = z.infer<typeof TransitionRequestSchema>;
|
export type TransitionRequest = z.infer<typeof TransitionRequestSchema>;
|
||||||
|
|
||||||
@@ -126,11 +131,13 @@ export const ReportUpsertSchema = z.object({
|
|||||||
externalCauseId: z.uuid().nullable().optional(),
|
externalCauseId: z.uuid().nullable().optional(),
|
||||||
actionTakenId: z.uuid().nullable().optional(),
|
actionTakenId: z.uuid().nullable().optional(),
|
||||||
componentConcernedId: z.uuid().nullable().optional(),
|
componentConcernedId: z.uuid().nullable().optional(),
|
||||||
|
baseUpdatedAt: z.iso.datetime().optional(), // verrou optimiste (D2)
|
||||||
});
|
});
|
||||||
export type ReportUpsert = z.infer<typeof ReportUpsertSchema>;
|
export type ReportUpsert = z.infer<typeof ReportUpsertSchema>;
|
||||||
|
|
||||||
export const ChecklistPatchSchema = z.object({
|
export const ChecklistPatchSchema = z.object({
|
||||||
state: z.enum(CHECKLIST_STATES),
|
state: z.enum(CHECKLIST_STATES),
|
||||||
|
baseUpdatedAt: z.iso.datetime().optional(), // verrou optimiste (D2)
|
||||||
});
|
});
|
||||||
export type ChecklistPatch = z.infer<typeof ChecklistPatchSchema>;
|
export type ChecklistPatch = z.infer<typeof ChecklistPatchSchema>;
|
||||||
|
|
||||||
|
|||||||
4725
pnpm-lock.yaml
generated
4725
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -11,3 +11,5 @@ allowBuilds:
|
|||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
'@scarf/scarf': false # télémétrie — bloquée
|
'@scarf/scarf': false # télémétrie — bloquée
|
||||||
'@prisma/client': true
|
'@prisma/client': true
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- expo-camera@57.0.3
|
||||||
|
|||||||
Reference in New Issue
Block a user