325 lines
21 KiB
Markdown
325 lines
21 KiB
Markdown
# Plano — MAI CALL v0.1 (ciclo mínimo)
|
|
|
|
> Autor: Opus 4.7 (sessão de design, 2026-05-16). Destinado a implementação pelo Sonnet.
|
|
> Estado do scaffold no momento do design: Next.js 15 + tRPC v11 + Prisma 6 + Postgres + Auth.js v5 beta + Tailwind 3 + shadcn (inlined) + Docker Compose + pnpm 11 monorepo + Turbo + Playwright. `packages/domain` vazio. Auth = dev-autologin sem password.
|
|
|
|
## Decisões fixadas (não revisitar sem motivo forte)
|
|
|
|
1. **Offline-first com fila persistente** (IndexedDB + service worker, sync on reconnect). Justificação: zonas pintura/Wi-Fi mau são restrição declarada do projeto.
|
|
2. **MinIO em Docker** como storage de fotos, acedido via AWS S3 SDK v3 (portável para cloud).
|
|
3. **Dev-autologin mantém-se na v0.1**, mas operador "escolhe-se" via picker que chama `signIn('credentials')` do Auth.js — não inventar canal de identidade paralelo.
|
|
4. **Destino: demo agora, piloto a seguir.** Abstracções certas (ObjectStorage interface), mas sem construir auth real / backup / observabilidade ainda.
|
|
|
|
## 1. Modelo de dados
|
|
|
|
Adiciona-se **um** modelo novo e mantém-se o `DomainEvent` existente como log de transições.
|
|
|
|
```prisma
|
|
enum MaintenanceRequestStatus {
|
|
OPEN
|
|
CLAIMED
|
|
RESOLVED
|
|
}
|
|
|
|
model MaintenanceRequest {
|
|
id String @id @default(cuid())
|
|
tenantId String
|
|
workstationId String
|
|
reportedByUserId String
|
|
description String // max 1000 chars (Zod)
|
|
photoKey String? // chave no MinIO; null se sem foto
|
|
status MaintenanceRequestStatus @default(OPEN)
|
|
clientRequestId String // idempotência (UUID do cliente)
|
|
createdAt DateTime @default(now())
|
|
|
|
claimedByUserId String?
|
|
claimedAt DateTime?
|
|
|
|
resolvedByUserId String?
|
|
resolvedAt DateTime?
|
|
resolutionNote String?
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
workstation Workstation @relation(fields: [workstationId], references: [id])
|
|
reportedBy User @relation("reported", fields: [reportedByUserId], references: [id])
|
|
claimedBy User? @relation("claimed", fields: [claimedByUserId], references: [id])
|
|
resolvedBy User? @relation("resolved", fields: [resolvedByUserId], references: [id])
|
|
|
|
@@unique([tenantId, clientRequestId]) // idempotência por tenant
|
|
@@index([tenantId, status, createdAt]) // alimenta fila do admin
|
|
@@index([tenantId, reportedByUserId])
|
|
}
|
|
```
|
|
|
|
Adicionar relations inversas em `User`, `Workstation`, `Tenant`. Acrescentar `'MaintenanceRequest'` a `TENANT_SCOPED_MODELS` em `packages/db/src/tenant-extension.ts`.
|
|
|
|
**Máquina de estados (deliberadamente mínima):**
|
|
|
|
- `OPEN` (estado inicial — qualquer OPERATOR/ADMIN/SUPERVISOR pode criar)
|
|
- → `CLAIMED` (ADMIN/SUPERVISOR fazem `claim`; grava `claimedByUserId`, `claimedAt`)
|
|
- → `RESOLVED` (apenas a partir de `CLAIMED`; grava `resolvedByUserId`, `resolvedAt`, `resolutionNote` opcional)
|
|
|
|
Sem reopen, sem cancel, sem "in-progress" separado de claimed. Transições inválidas devolvem 409.
|
|
|
|
A cada transição emite-se um `DomainEvent` (`aggregateType='MaintenanceRequest'`, `eventType ∈ {'created','claimed','resolved'}`) na mesma transacção Prisma. Custo: ~5 linhas; benefício: relatório de fim de turno e auditoria caem-nos no colo depois sem refactor.
|
|
|
|
**`clientRequestId`** é fundamental para offline-first: o service worker pode tentar submeter duas vezes; o `@@unique([tenantId, clientRequestId])` garante idempotência. O endpoint trata violação de unique como "already created, return existing".
|
|
|
|
## 2. tRPC — procedures
|
|
|
|
Tudo `protectedProcedure`. Sem necessidade de procedures públicas neste módulo.
|
|
|
|
| Router.procedure | Tipo | Quem | Input | Output |
|
|
|---|---|---|---|---|
|
|
| `maintenanceRequest.create` | mutation | OPERATOR+ | `{ workstationId, description, photoKey?, clientRequestId }` | `{ id, status, createdAt }` |
|
|
| `maintenanceRequest.queue` | query | ADMIN/SUPERVISOR | `{ statuses?: Status[], area?: string }` | lista paginada (cursor) |
|
|
| `maintenanceRequest.myRecent` | query | OPERATOR | `{ limit?: number }` | últimos N do próprio user |
|
|
| `maintenanceRequest.claim` | mutation | ADMIN/SUPERVISOR | `{ id }` | request actualizado |
|
|
| `maintenanceRequest.resolve` | mutation | ADMIN/SUPERVISOR | `{ id, resolutionNote? }` | request actualizado |
|
|
| `maintenanceRequest.getById` | query | qualquer (tenant scope) | `{ id }` | request com relations |
|
|
| `workstation.list` | query | qualquer | — | `Workstation[]` |
|
|
| `user.listOperators` | query | qualquer | — | `{ id, email }[]` filtrado por role=OPERATOR |
|
|
| `storage.signPhotoUpload` | mutation | OPERATOR+ | `{ contentType, byteSize }` | `{ uploadUrl, photoKey, expiresAt }` |
|
|
| `storage.signPhotoDownload` | query | qualquer | `{ photoKey }` | `{ url, expiresAt }` (curta) |
|
|
|
|
Acrescenta-se **role middleware** simples em `packages/api/src/trpc.ts`: `requireRole(...roles)` derivado de `protectedProcedure`. Usado nas mutations admin (`claim`, `resolve`).
|
|
|
|
Validação Zod:
|
|
|
|
- `description`: `z.string().trim().min(3).max(1000)`
|
|
- `photoKey`: regex `^tenants/[a-z0-9-]+/maintenance/[a-z0-9-]+\.(jpg|jpeg|png|webp)$`
|
|
- `byteSize`: `z.number().int().min(1).max(10 * 1024 * 1024)` (10 MB cap)
|
|
- `contentType`: enum `['image/jpeg','image/png','image/webp']`
|
|
|
|
## 3. Storage — abstracção + MinIO
|
|
|
|
Cria-se `packages/storage` com:
|
|
|
|
```ts
|
|
export interface ObjectStorage {
|
|
signPut(key: string, contentType: string, byteSize: number, ttlSec: number): Promise<{ url: string; expiresAt: Date }>;
|
|
signGet(key: string, ttlSec: number): Promise<{ url: string; expiresAt: Date }>;
|
|
delete(key: string): Promise<void>;
|
|
}
|
|
export function makeStorage(): ObjectStorage { /* lê env, devolve MinIO impl */ }
|
|
```
|
|
|
|
Impl única na v0.1: `MinioStorage` usando `@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner` (a mesma SDK serve AWS, R2, Wasabi, MinIO — só muda endpoint). **Não** instalar SDK específica do MinIO; manter S3 protocol universal é o ponto.
|
|
|
|
**Compose** ganha:
|
|
|
|
```yaml
|
|
minio:
|
|
image: minio/minio:latest
|
|
command: server /data --console-address ":9001"
|
|
environment: { MINIO_ROOT_USER, MINIO_ROOT_PASSWORD }
|
|
ports: ["9000:9000", "9001:9001"]
|
|
volumes: ["minio-data:/data"]
|
|
minio-init: # job one-shot que cria o bucket fieldops
|
|
image: minio/mc:latest
|
|
depends_on: { minio: { condition: service_started } }
|
|
entrypoint: >
|
|
/bin/sh -c "mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
|
mc mb -p local/fieldops || true &&
|
|
mc anonymous set none local/fieldops"
|
|
```
|
|
|
|
Env novas: `S3_ENDPOINT`, `S3_REGION` (us-east-1 default p/ MinIO), `S3_BUCKET=fieldops`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_FORCE_PATH_STYLE=true`. Validar em `apps/operator-pwa/env.ts` e no admin-web.
|
|
|
|
**Convenção de chaves:** `tenants/{tenantId}/maintenance/{requestId-or-clientRequestId}.{ext}`. Determinismo da chave permite ao cliente pre-calcular antes de submeter — essencial para o fluxo offline (foto sobe primeiro, mutation guarda só a key).
|
|
|
|
Lifecycle: nenhum (v0.1). Backup: documenta-se `mc mirror` no README; sem cron ainda.
|
|
|
|
## 4. Identidade do operador
|
|
|
|
Decisão: **manter dev-autologin, mas usar a Auth.js signIn já existente** para distinguir operadores em vez de inventar um canal paralelo.
|
|
|
|
Concretamente:
|
|
|
|
- O picker da PWA chama `signIn('credentials', { email, redirect: false })` quando o operador se selecciona. Credentials provider já aceita email-sem-password (`apps/operator-pwa/lib/auth.ts:38-58`), portanto não precisa de mudança.
|
|
- `resolveUser()` continua igual: a sessão Auth.js real ganha precedência sobre `AUTH_DEV_AUTOLOGIN`. O fallback admin só dispara quando ninguém ainda escolheu (primeiro load).
|
|
- A escolha persiste via cookie de sessão Auth.js — sobrevive a reload e a fechar/abrir tab.
|
|
- "Trocar utilizador" é um botão que chama `signOut` + manda para o picker.
|
|
|
|
Isto deixa o **TAG/cartão para o MY QUALITY** num caminho limpo: substituis o picker por um leitor de RFID que devolve um email/id de operador → chamas o mesmo `signIn`. Zero refactor no resto do sistema.
|
|
|
|
Para o admin-web: continua com `AUTH_DEV_AUTOLOGIN=true` a entrar como `admin@demo.local`. Sem picker do lado admin na v0.1 — é "demo, qualquer um que abra é o admin".
|
|
|
|
## 5. UI Operador (`operator-pwa`)
|
|
|
|
Quatro ecrãs. Mobile-first, target tablet/telemóvel em portrait.
|
|
|
|
**E1 — Picker de operador** (`/select-operator`)
|
|
|
|
- Lista grande, tap-friendly, dos OPERATOR do tenant. Avatar opcional, label = email ou nome.
|
|
- Tap → `signIn('credentials')` → redirect a `/`.
|
|
- Skip automático se já houver sessão.
|
|
|
|
**E2 — Home** (`/`)
|
|
|
|
- Header: nome do operador + botão "Trocar".
|
|
- Estado de sync: chip "Tudo sincronizado" / "N pedidos por enviar" se a fila IndexedDB não estiver vazia.
|
|
- Botão grande primário: **"Pedir manutenção"** → E3.
|
|
- Lista colapsada "Os meus pedidos" (últimos 5): query `myRecent` + união com pendentes da IndexedDB (status local "pending"). Cores por status. Sem detalhes — só feedback.
|
|
|
|
**E3 — Novo pedido** (`/maintenance/new`)
|
|
|
|
- Campo 1 — **Posto**: dropdown searchable (Combobox da shadcn). Itens vêm de `workstation.list` com cache SWR longo. Sticky "Recentes" no topo (localStorage dos últimos 3 escolhidos). Obrigatório.
|
|
- Campo 2 — **Foto** (opcional): `<input type="file" accept="image/*" capture="environment" />`. Preview redimensionado client-side com `<canvas>` para max 1600px lado maior + compressão JPEG q=0.8 (reduz upload em zona pintura). Botão "Remover foto".
|
|
- Campo 3 — **Descrição**: textarea, 3-1000 chars, contador. Obrigatório.
|
|
- Submeter:
|
|
1. Gerar `clientRequestId = crypto.randomUUID()`.
|
|
2. Gerar `photoKey` deterministicamente: `tenants/{tenantId}/maintenance/{clientRequestId}.jpg`.
|
|
3. Enfileirar em IndexedDB (Dexie): `{ clientRequestId, photoKey, photoBlob, workstationId, description, queuedAt }`.
|
|
4. Marcar SW para sync.
|
|
5. Navegar a `/maintenance/sent?cid={clientRequestId}` (E4) **sem esperar pelo servidor**.
|
|
|
|
**E4 — Confirmação** (`/maintenance/sent`)
|
|
|
|
- Mostra "Pedido em fila" (se ainda em IndexedDB) ou "Pedido enviado #abc" (se sincronizado). Reactivo a updates do sync. Botão "Voltar".
|
|
|
|
**Service worker + sync (offline-first):**
|
|
|
|
- Lib: `workbox` (caching app shell) + Dexie 4 (queue).
|
|
- App shell precaching: HTML/JS/CSS estáticos, ícones, fontes.
|
|
- Runtime caching: `workstation.list` e `user.listOperators` com StaleWhileRevalidate (servem picker e dropdown offline).
|
|
- **Worker de sync** (lógica em TS, corrida no main thread quando o tab estiver visível + Background Sync API quando suportado):
|
|
- Loop: enquanto fila não-vazia e online (`navigator.onLine` + ping a `/api/health`):
|
|
- Para cada item: (a) `signPhotoUpload` → PUT directo ao MinIO (se houver foto); (b) `maintenanceRequest.create`; (c) remove da fila ao sucesso; (d) retry com backoff exponencial em falhas de rede; (e) erros 4xx (excepto 409) → move para "dead-letter" local + toast ao operador "Pedido X falhou, contactar supervisor".
|
|
- 409 (duplicate `clientRequestId`) é tratado como sucesso — robustez face a retries duplicados.
|
|
- UI tem que reagir: emite eventos `BroadcastChannel('mai-call-sync')` que o React assina.
|
|
|
|
**Critério-chave:** desligar o Wi-Fi do tablet, criar 3 pedidos, ligar de novo. Os 3 chegam ao admin-web sem o operador fazer nada.
|
|
|
|
## 6. UI Manutenção (`admin-web`)
|
|
|
|
Uma única página `/maintenance` (sem ainda preocupação com dashboard, login screen, settings).
|
|
|
|
- Header: contador "**N pedidos abertos**" + filtros (status multi-select, área dropdown).
|
|
- Lista (cards verticais; tabela é hostil em ecrã pequeno):
|
|
- Thumbnail foto (signed GET URL com TTL curto, lazy-loaded).
|
|
- Posto (`{code} — {name} • {area}`).
|
|
- Descrição.
|
|
- "Reportado por {operador} • há {Xm}".
|
|
- Footer: badge status, e dependendo do estado:
|
|
- `OPEN` → botão **Aceitar** (mutation `claim`).
|
|
- `CLAIMED` (por mim ou outro): mostra "Aceite por {user} há {Xm}" + botão **Marcar resolvido** (abre dialog com `resolutionNote` opcional, mutation `resolve`).
|
|
- `RESOLVED`: badge verde, "Resolvido por {user} há {Xm}", sem acções.
|
|
|
|
**Real-time: polling.** `queue` faz `useQuery` com `refetchInterval: 5000` e `refetchIntervalInBackground: false`. Justificação:
|
|
|
|
1. SSE/WS exigem canal extra no Next route handler + auth no upgrade + reconexão. Polling de 5s em fila de manutenção fabril é amplamente suficiente — não é trading.
|
|
2. Migrar para SSE depois é trivial e isolado a esta query (`tRPC subscriptions` ou endpoint SSE puro).
|
|
|
|
**Notificações:** badge no `document.title` (`(3) FieldOps — Maintenance`) + `<audio>` curto opcional ao detectar novo pedido OPEN (toggle persistido em localStorage). Sem push, sem email, sem websocket. Custos quase zero, valor enorme.
|
|
|
|
## 7. Cortes propositados — o que NÃO entra na v0.1
|
|
|
|
| Cortado | Porquê | Quando volta |
|
|
|---|---|---|
|
|
| Severidade / categoria | UX-cost na linha sem valor demonstrado; defaults razoáveis funcionam | v0.2, junto com SLAs |
|
|
| SLAs / alertas / timers | Requer infra de scheduling | v0.2 |
|
|
| Relatório fim de turno automático | DomainEvents já registados → gerar depois sem refactor | v0.2 ou v0.3 |
|
|
| Múltiplas fotos / vídeo / áudio | Triplica complexidade upload/storage | quando piloto pedir |
|
|
| Reabrir pedido fechado / chat operador-técnico | Workflow real, não MVP | v0.2+ |
|
|
| Categorização AI da descrição | Tentação clássica de over-engineer | só com dados reais |
|
|
| Export CSV/PDF | Stakeholder pedirá; resposta é "ver Insights & Sim" | módulo separado |
|
|
| Push notifications mobile | PWA push web tem suporte Apple irregular; badge+polling chega | só se piloto exigir |
|
|
| Auth real (passwords, SSO, MFA) | Definido como demo→piloto; v0.1 dev-autologin | v0.2, pré-piloto |
|
|
| TAG/cartão | Substitui-se o picker; arquitectura já preparada | quando MY QUALITY entrar |
|
|
| Multi-tenant onboarding UI | Cria-se via seed/SQL | só com 2º cliente |
|
|
| i18n PT/EN | Hardcoded PT na v0.1 (cliente é Mangualde) | v0.2 com cliente piloto |
|
|
| Testes E2E completos | 1 happy-path Playwright basta na v0.1 | expandir com piloto |
|
|
| Observabilidade | Logs estruturados via `logger` já existente; chega | quando piloto exigir |
|
|
|
|
## 8. Próximo módulo a ligar — MY QUALITY
|
|
|
|
Razão: reusa Workstation + identidade-de-operador-num-posto, não introduz integração brownfield nova, e fecha o segundo case de uso de "operador na linha que reporta algo". MAI BOM exige integração com stock/MAI (mais canalização); SWA DIGITAL exige standard work formalizado (mais conteúdo); Paperless precisa de forms engine. MY QUALITY é o mais barato a seguir.
|
|
|
|
**O que o MAI CALL v0.1 deve já deixar preparado (sem código extra agora, só desenho que não fecha portas):**
|
|
|
|
1. **`OperatorSession` é um futuro modelo, não inventes ainda.** Mas o picker de operador da PWA já se comporta como o "badge-in" futuro: escolher operador é uma acção persistente, não uma form-field por pedido. Quando MY QUALITY chegar, acrescenta-se `OperatorSession(tenantId, userId, workstationId, startedAt, endedAt?)` e o picker passa a perguntar **também** o posto (binding operador↔posto), o que substitui o dropdown de posto em E3 do MAI CALL (deixa de ser por-pedido, passa a vir da sessão). Refactor pequeno, contido a 1 ecrã.
|
|
2. **`DomainEvent` já registado** alimenta queries cross-módulo ("últimos 10 eventos no posto X") sem schema novo.
|
|
3. **Storage abstrato** (`ObjectStorage`) serve também as fotos de defeitos do QCP.
|
|
4. **Polling pattern** em `admin-web` generaliza-se trivialmente para a vista de roteamento RFS→operador.
|
|
|
|
## 9. Plano de implementação (passos pequenos, ordenados)
|
|
|
|
Cada passo é mergeable independentemente, com critério de aceitação testável.
|
|
|
|
### Passo 1 — Schema + extension
|
|
|
|
**Faz:** Adicionar `MaintenanceRequest` + enum + relations + indexes ao schema. Acrescentar `'MaintenanceRequest'` aos modelos tenant-scoped. Criar migration.
|
|
**AC:** `pnpm db:migrate` corre sem erro; `prisma studio` mostra a tabela vazia; `select * from "MaintenanceRequest" where "tenantId"='x'` funciona.
|
|
|
|
### Passo 2 — Seed alargado
|
|
|
|
**Faz:** Estender `db:seed` para criar 3 OPERATOR users (`op1@demo.local`, `op2@demo.local`, `op3@demo.local`) e 3 Workstations (`CTR04`, `QVN_RTL_2`, `MTG_01`).
|
|
**AC:** Após `pnpm db:seed`, picker em http://localhost:3000 lista 3 operadores; dropdown de posto lista 3 postos.
|
|
|
|
### Passo 3 — Pacote `@repo/storage`
|
|
|
|
**Faz:** Criar `packages/storage` com a interface `ObjectStorage` e impl `MinioStorage` usando AWS SDK v3. Adicionar env vars S3_*. Adicionar serviço MinIO + minio-init ao `docker-compose.yml`.
|
|
**AC:** Script ad-hoc `pnpm tsx scripts/storage-smoke.ts` consegue: gerar presigned PUT, fazer upload com `curl`, gerar presigned GET, descarregar o ficheiro de volta.
|
|
|
|
### Passo 4 — Role middleware em tRPC
|
|
|
|
**Faz:** Acrescentar `requireRole(...roles)` em `packages/api/src/trpc.ts` que estende `protectedProcedure`.
|
|
**AC:** Procedure de teste com `requireRole('ADMIN')` devolve 403 para OPERATOR; passa para ADMIN.
|
|
|
|
### Passo 5 — Router `workstation` + `user`
|
|
|
|
**Faz:** Criar `workstation.list` (todos) e `user.listOperators` (filter role=OPERATOR). Montar em `_app.ts`.
|
|
**AC:** Chamada por tRPC client devolve seeds do Passo 2.
|
|
|
|
### Passo 6 — Router `storage`
|
|
|
|
**Faz:** Criar `storage.signPhotoUpload` (mutation) e `storage.signPhotoDownload` (query). Validar Zod (content-type, size). TTL: 5min upload, 1min download.
|
|
**AC:** Chamada por tRPC devolve URL válida; upload direto ao MinIO com a URL funciona.
|
|
|
|
### Passo 7 — Router `maintenanceRequest`
|
|
|
|
**Faz:** Procedures `create`, `claim`, `resolve`, `queue`, `myRecent`, `getById`. Cada transição emite `DomainEvent` na mesma `$transaction` (usar pattern de scoped tx do header de `tenant-extension.ts`). Tratar `P2002` em `create` (unique violation em clientRequestId) como sucesso devolvendo a request existente.
|
|
**AC:** Teste integração: cria-se um pedido por op1; claim por admin; resolve por admin; queue mostra na ordem certa. Repetir `create` com mesmo `clientRequestId` devolve a mesma row sem erro.
|
|
|
|
### Passo 8 — Picker de operador
|
|
|
|
**Faz:** Ecrã `/select-operator` na operator-pwa. Lista `user.listOperators`. Tap → `signIn('credentials', { email, redirect: false })`. Redirect a `/`. Middleware `middleware.ts` redirecciona a `/select-operator` se não houver sessão e não houver autologin.
|
|
**AC:** Demo flow: limpar cookies, abrir `/`, é redirigido ao picker, escolher op1, voltar a `/`, header mostra `op1@demo.local`.
|
|
|
|
### Passo 9 — Home da PWA
|
|
|
|
**Faz:** Substituir `/` actual por: header com operador + botão trocar; botão "Pedir manutenção" → `/maintenance/new`; lista "Os meus pedidos" via `myRecent`.
|
|
**AC:** Botão visível, navega correctamente.
|
|
|
|
### Passo 10 — Ecrã novo pedido (online-only primeiro)
|
|
|
|
**Faz:** `/maintenance/new` com posto (Combobox), foto (input capture, compress canvas), descrição. Submissão **directa** ao servidor (sem fila ainda). Confirmação em `/maintenance/sent`.
|
|
**AC:** Submeter um pedido (com e sem foto) cria a row, MinIO tem o objecto, redirect mostra ID.
|
|
|
|
### Passo 11 — Admin-web `/maintenance`
|
|
|
|
**Faz:** Page com lista (cards), filtros simples (status/area), polling 5s, botões claim/resolve (este último abre dialog com note opcional). Thumbnails via signed GET. Badge no title.
|
|
**AC:** Pedido criado no Passo 10 aparece em ≤5s; claim move o card para CLAIMED; resolve move para RESOLVED.
|
|
|
|
### Passo 12 — IndexedDB + service worker + sync
|
|
|
|
**Faz:** Instalar Dexie + Workbox. Refactorizar E3 para enfileirar em IndexedDB e devolver controlo imediatamente. Service worker faz precache de app shell + StaleWhileRevalidate para `workstation.list` e `user.listOperators`. Worker de sync com retry/backoff. UI mostra "N por enviar" e reage via BroadcastChannel.
|
|
**AC:** Cenário offline: Chrome DevTools → Network=Offline → criar 3 pedidos com 1 foto cada → vê-los listados como "por enviar" → Network=Online → em ≤30s os 3 estão no admin-web com fotos.
|
|
|
|
### Passo 13 — Polish + happy-path E2E
|
|
|
|
**Faz:** Tratamento de erros visíveis (toasts), loading states, empty states. Um único teste Playwright que percorre operador-cria → admin-claim → admin-resolve.
|
|
**AC:** `pnpm test:e2e` verde.
|
|
|
|
### Passo 14 — README + runbook curto
|
|
|
|
**Faz:** Atualizar README com (a) como subir o stack (`docker compose up` + `pnpm db:migrate` + `pnpm db:seed` + `pnpm dev`); (b) credenciais MinIO/console em http://localhost:9001; (c) limitações conhecidas v0.1 (dev-autologin, sem auth real, demo only).
|
|
**AC:** Outro dev clona o repo, segue o README, vê o demo flow a funcionar em <15 min.
|
|
|
|
---
|
|
|
|
**Sequência crítica:** Passos 1→7 são backend e dão-se naturalmente em PRs pequenos. Passo 8 é o pivot para frontend. Passos 9-11 podem ir em paralelo (operator-pwa vs admin-web) se houver 2 devs. Passo 12 é o passo mais arriscado/longo — deixar para o fim, depois de o fluxo online estar verificado, para não misturar bugs de sync com bugs de domínio.
|
|
|
|
**Risco principal não-mitigado:** comportamento do service worker no Safari iOS (caso a fábrica use iPads). Workbox lida com a maioria mas Background Sync API não é suportada — cai-se no fallback "sync quando o tab está visível". Aceitar e seguir.
|