feat(api): adaptar cliente al contrato v1.1
- feature_types: nueva tabla local + tipo FeatureType + upsert en applyBundle + getFeatureTypes(); FeatureDetail muestra badge del tipo con su color. - feature: columnas feature_type_id + is_active (migración BD v3); feature.update acepta ambas; toggle "Activa" en el detalle con badge "inactiva" cuando corresponde. - templates: ahora catálogo global asignado por pivot — getTemplates() ya no filtra por project_id (informativo); phase_id eliminado del tipo y del pick. - Formulario de inspección: soporta fields[].group (secciones), question (etiqueta principal), help (texto de ayuda) y required (validación al guardar con aviso de campos faltantes). - Bundle: tipa issue_tasks correctamente como IssueTask[]. - docs/: sincronizados openapi.yaml y MOBILE_APP_BRIEF.md v1.1 del backend. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+45
-23
@@ -1,5 +1,7 @@
|
||||
# ConstruProgress — Brief para la App Móvil
|
||||
|
||||
**Contrato de la API móvil (v1.1)** — actualizado 2026-07-07.
|
||||
|
||||
Documento único de traspaso para construir la app móvil que consume la API de
|
||||
ConstruProgress. La **fuente de verdad** del contrato es [`openapi.yaml`](openapi.yaml);
|
||||
el modelo offline está en [`MOBILE_SYNC_PROTOCOL.md`](MOBILE_SYNC_PROTOCOL.md). Este
|
||||
@@ -13,9 +15,9 @@ brief los resume y añade ejemplos de payloads reales y el modelo de datos.
|
||||
## 1. Objetivo
|
||||
|
||||
App de **seguimiento de obra** que funciona **sin conexión** en campo: descarga los
|
||||
datos de un proyecto (estructura + plantillas), permite trabajar offline (actualizar
|
||||
progreso, registrar inspecciones, gestionar incidencias con tareas/comentarios/fotos)
|
||||
y **sincroniza cuando hay red**.
|
||||
datos de un proyecto (estructura + plantillas asignadas), permite trabajar offline
|
||||
(actualizar progreso, registrar inspecciones con fotos, gestionar incidencias con
|
||||
tareas/comentarios/fotos) y **sincroniza cuando hay red**.
|
||||
|
||||
## 2. Autenticación (Laravel Sanctum)
|
||||
|
||||
@@ -25,8 +27,7 @@ y **sincroniza cuando hay red**.
|
||||
- `POST /logout` revoca el token del dispositivo actual.
|
||||
- Guarda el token en almacenamiento seguro (Expo SecureStore / flutter_secure_storage).
|
||||
|
||||
**Base URL:** `https://<host>/api/v1` (confirmar host de despliegue; en local XAMPP
|
||||
suele ser `http://localhost/construprogress/public/api/v1`).
|
||||
**Base URL:** `https://<host>/api/v1` (confirmar host de despliegue).
|
||||
|
||||
```bash
|
||||
curl -X POST https://<host>/api/v1/login \
|
||||
@@ -44,9 +45,9 @@ curl -X POST https://<host>/api/v1/login \
|
||||
| POST | `/logout` | Revocar token | — |
|
||||
| GET | `/projects` | Proyectos accesibles | — |
|
||||
| GET | `/projects/{id}/bundle?since=` | PULL: snapshot o delta + tombstones | — |
|
||||
| GET | `/templates?since=` | Plantillas de inspección (version+hash) | — |
|
||||
| GET | `/templates?since=` | Plantillas **asignadas** a proyectos accesibles | — |
|
||||
| POST | `/sync` | PUSH: lote de mutaciones offline | 60/min |
|
||||
| POST | `/media` | Subir fichero (multipart) | 120/min |
|
||||
| POST | `/media` | Subida de ficheros (multipart) | 120/min |
|
||||
|
||||
## 4. PULL — descarga de datos
|
||||
|
||||
@@ -55,16 +56,17 @@ curl -X POST https://<host>/api/v1/login \
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"server_time": "2026-06-18T12:00:00+00:00", // úsalo como próximo `since`
|
||||
"server_time": "2026-07-07T12:00:00+00:00", // úsalo como próximo `since`
|
||||
"project": { ... },
|
||||
"phases": [ ... ],
|
||||
"layers": [ ... ],
|
||||
"features": [ ... ],
|
||||
"feature_types": [ ... ], // catálogo global (id, name, color)
|
||||
"inspections": [ ... ],
|
||||
"issues": [ ... ],
|
||||
"issue_tasks": [ ... ],
|
||||
"issue_comments": [ ... ],
|
||||
"templates": [ ... ],
|
||||
"issue_tasks": [ ... ], // checklist por incidencia
|
||||
"issue_comments": [ ... ], // comentarios por incidencia
|
||||
"templates": [ ... ], // asignadas al proyecto (vía pivot)
|
||||
"media": [ ... ],
|
||||
"deleted": {} // vacío en snapshot completo
|
||||
}
|
||||
@@ -84,9 +86,15 @@ y un objeto `deleted` con los **ids borrados** (tombstones) por entidad:
|
||||
> ⚠️ **URL-encodea el `since`** (el `+` del offset horario). Guarda `server_time` de
|
||||
> cada respuesta y úsalo como el siguiente `since`.
|
||||
|
||||
### 4.3 Plantillas
|
||||
`GET /templates?since=` devuelve las plantillas de inspección de los proyectos
|
||||
accesibles, cada una con `version` (timestamp) y `hash` (para detectar cambios).
|
||||
### 4.3 Plantillas (globales, con asignación por proyecto)
|
||||
`GET /templates?since=` devuelve las plantillas del **catálogo global** que están
|
||||
**asignadas** a alguno de los proyectos accesibles del usuario (vía pivot
|
||||
`inspection_template_project`). Cada plantilla trae `version` (timestamp) y `hash`.
|
||||
|
||||
> **Cambio de v1.0 → v1.1:** las plantillas ya **no** viven por proyecto. Son un
|
||||
> catálogo global, y los proyectos consumen las que se les han asignado.
|
||||
> `template.phase_id` se **eliminó**. `template.project_id` sigue en el JSON por
|
||||
> compatibilidad pero **no** determina visibilidad — trátalo como informativo.
|
||||
|
||||
## 5. PUSH — `POST /sync`
|
||||
|
||||
@@ -100,7 +108,7 @@ idempotencia) y `client_updated_at`:
|
||||
"entity": "feature",
|
||||
"op": "update",
|
||||
"uuid": "0f8e2b6c-....", // único y estable por operación
|
||||
"client_updated_at": "2026-06-18T11:30:00+00:00",
|
||||
"client_updated_at": "2026-07-07T11:30:00+00:00",
|
||||
"data": { "id": 5, "status": "completed", "progress": 100 }
|
||||
}
|
||||
]
|
||||
@@ -119,12 +127,12 @@ Respuesta — **un resultado por operación**:
|
||||
(last-write-wins por `client_updated_at`). Resuélvelo en el cliente y reintenta.
|
||||
- `error`: trae `"error": "..."` (validación o permisos).
|
||||
|
||||
### 5.1 Operaciones soportadas (entity.op → data → permiso requerido)
|
||||
### 5.1 Operaciones soportadas
|
||||
|
||||
| entity.op | `data` | Permiso |
|
||||
|---|---|---|
|
||||
| `progress_update.create` | `{ phase_id, progress(0-100), comment?, location? }` | `update progress` |
|
||||
| `feature.update` | `{ id, status?, progress?(0-100), responsible? }` | `update progress` |
|
||||
| `feature.update` | `{ id, status?, progress?(0-100), responsible?, is_active?, feature_type_id? }` | `update progress` |
|
||||
| `inspection.create` | `{ feature_id, template_id?, data?, status?, result?, notes? }` | `create inspections` |
|
||||
| `issue.create` | `{ project_id, feature_id?, title, description?, priority?, status?, type? }` | `create issues` |
|
||||
| `issue.update` | `{ id, title?, description?, priority?, status?, type?, assigned_to?, resolution_notes? }` | `edit issues` |
|
||||
@@ -164,7 +172,8 @@ project : id, reference, name, address, lat, lng, status, updated_at
|
||||
phase : id, name, order, color, progress_percent, updated_at
|
||||
layer : id, phase_id, name, color, updated_at
|
||||
feature : id, layer_id, name, geometry(GeoJSON), status, progress,
|
||||
responsible, template_id, updated_at
|
||||
responsible, template_id, feature_type_id, is_active, updated_at
|
||||
feature_type : id, name, color # catálogo global
|
||||
inspection : id, feature_id, layer_id, template_id, user_id, data(obj),
|
||||
status, result, notes, created_at, updated_at
|
||||
issue : id, feature_id, title, description, status, priority, type,
|
||||
@@ -172,8 +181,13 @@ issue : id, feature_id, title, description, status, priority, type,
|
||||
issue_task : id, issue_id, title, is_done, done_at, done_by, assigned_to,
|
||||
due_date, order, updated_at
|
||||
issue_comment : id, issue_id, user_id, body, created_at, updated_at
|
||||
template : id, project_id, phase_id, name, description, fields(array),
|
||||
version, hash, updated_at
|
||||
template : id, name, description, fields(array), version, hash,
|
||||
project_id(informativo, puede ser null), updated_at
|
||||
# fields[]: cada campo puede incluir:
|
||||
# group (string, sección para agrupar)
|
||||
# question (string, prompt corto en la inspección)
|
||||
# help (string, ayuda/comentarios largos)
|
||||
# name, label, type, required, options, min, max, step
|
||||
media : id, uuid, parent_entity, parent_id, url, name, file_type,
|
||||
category, updated_at
|
||||
```
|
||||
@@ -193,6 +207,13 @@ media : id, uuid, parent_entity, parent_id, url, name, file_type,
|
||||
la `url` devuelta.
|
||||
5. **Token**: en almacenamiento seguro; si 401 → re-login.
|
||||
|
||||
### Renderizar plantillas de inspección
|
||||
Cada plantilla trae `fields[]` con posibles metadatos: `group` (agrupar campos por
|
||||
sección), `question` (mostrar como prompt), `help` (bajo el campo). El resto de
|
||||
campos (`name`, `label`, `type`, `required`, `options`, `min`, `max`, `step`) sigue
|
||||
igual. Al enviar la inspección (`inspection.create`), `data` es un objeto plano
|
||||
`{ nombre_del_campo: valor }` — los grupos son solo de presentación.
|
||||
|
||||
### Flujo típico de sesión
|
||||
```
|
||||
login → guardar token
|
||||
@@ -206,7 +227,8 @@ con red: POST /sync (lote) → POST /media (ficheros) → GET bundle?since=serve
|
||||
- [ ] Elegir stack (RN+Expo / Flutter) y crear el proyecto.
|
||||
- [ ] `claude --add-dir C:\xampp\htdocs\construprogress` para tener el contrato a mano.
|
||||
- [ ] Capa de API (login/me/logout, projects, bundle, templates, sync, media).
|
||||
- [ ] BD local + repositorios por entidad.
|
||||
- [ ] BD local + repositorios por entidad (incluyendo feature_types, issue_tasks,
|
||||
issue_comments, y templates con `fields[].group/question/help`).
|
||||
- [ ] Motor de sync (PULL delta + outbox PUSH + media) con manejo de conflictos.
|
||||
- [ ] UI: lista de proyectos, mapa/fases, inspecciones, incidencias (checklist,
|
||||
comentarios, fotos), indicador de estado de sincronización.
|
||||
- [ ] UI: lista de proyectos, mapa/fases, inspecciones (grupos + fotos), incidencias
|
||||
(checklist, comentarios, fotos), indicador de estado de sincronización.
|
||||
|
||||
+59
-6
@@ -1,11 +1,37 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: ConstruProgress Mobile API
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
description: >
|
||||
Offline-first sync API for the mobile app. Auth via Laravel Sanctum bearer
|
||||
tokens (ability `mobile-sync`). All protected endpoints require
|
||||
`Authorization: Bearer <token>`. See docs/MOBILE_SYNC_PROTOCOL.md.
|
||||
|
||||
|
||||
## Changelog (from v1.0)
|
||||
|
||||
|
||||
- Inspection templates are now **global** and assigned to projects via
|
||||
pivot. `bundle.templates` and `/templates` return templates *assigned* to
|
||||
the accessible projects (no longer filtered by `project_id`).
|
||||
`template.phase_id` was **removed**. `project_id` remains in the schema
|
||||
for compatibility but no longer determines visibility.
|
||||
|
||||
- Template `fields[]` items may include `group` (section), `question` (short
|
||||
prompt shown at inspection time) and `help` (long help text).
|
||||
|
||||
- New entities in the bundle: `feature_types` (global catalogue),
|
||||
`issue_tasks`, `issue_comments`.
|
||||
|
||||
- `feature` gains `feature_type_id`, `is_active`; `issue` gains `type`.
|
||||
|
||||
- New `/sync` ops: `issue.update`, `issue_task.create`, `issue_task.update`,
|
||||
`issue_comment.create`. `feature.update` accepts `is_active` and
|
||||
`feature_type_id`; `issue.create`/`update` accept `type`.
|
||||
|
||||
- `POST /media` `parent_entity` also accepts `issue_task` and `issue_comment`.
|
||||
|
||||
- `deleted` tombstones include `issue_tasks` and `issue_comments`.
|
||||
servers:
|
||||
- url: /api/v1
|
||||
security:
|
||||
@@ -85,7 +111,12 @@ paths:
|
||||
"403": { description: Not a member of the project }
|
||||
/templates:
|
||||
get:
|
||||
summary: Inspection templates for accessible projects (with version/hash)
|
||||
summary: Global inspection templates ASSIGNED to any accessible project (via pivot)
|
||||
description: >
|
||||
Templates are a global catalogue. This endpoint returns the templates
|
||||
that have been *assigned* (via `inspection_template_project` pivot) to
|
||||
any of the user's accessible projects. Each item includes `version`
|
||||
(updated_at timestamp) and `hash` for change detection.
|
||||
parameters:
|
||||
- name: since
|
||||
in: query
|
||||
@@ -183,11 +214,33 @@ components:
|
||||
phases: { type: array, items: { type: object } }
|
||||
layers: { type: array, items: { type: object } }
|
||||
features: { type: array, items: { type: object } }
|
||||
feature_types:
|
||||
type: array
|
||||
description: Global catalogue of feature types (id, name, color)
|
||||
items: { type: object }
|
||||
inspections: { type: array, items: { type: object } }
|
||||
issues: { type: array, items: { type: object } }
|
||||
issue_tasks: { type: array, items: { type: object } }
|
||||
issue_comments: { type: array, items: { type: object } }
|
||||
templates: { type: array, items: { type: object } }
|
||||
issues:
|
||||
type: array
|
||||
description: >
|
||||
Includes `type` (defect|safety|quality|documentation|other),
|
||||
`status` (open|in_review|resolved|closed),
|
||||
`priority` (low|medium|high|critical).
|
||||
items: { type: object }
|
||||
issue_tasks:
|
||||
type: array
|
||||
description: Checklist tasks per issue.
|
||||
items: { type: object }
|
||||
issue_comments:
|
||||
type: array
|
||||
description: Comment thread per issue.
|
||||
items: { type: object }
|
||||
templates:
|
||||
type: array
|
||||
description: >
|
||||
Templates assigned to this project via pivot (no longer filtered by
|
||||
project_id). Each item exposes version/hash for change detection and
|
||||
`fields[]` where each field may include `group`, `question`, `help`.
|
||||
items: { type: object }
|
||||
media: { type: array, items: { type: object } }
|
||||
deleted:
|
||||
type: object
|
||||
|
||||
+21
-3
@@ -65,9 +65,19 @@ export interface Feature {
|
||||
progress?: number;
|
||||
responsible?: string | null;
|
||||
template_id?: number | null;
|
||||
feature_type_id?: number | null;
|
||||
is_active?: boolean | number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Catálogo global de tipos de feature (v1.1). */
|
||||
export interface FeatureType {
|
||||
id: number;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Inspection {
|
||||
id: number;
|
||||
feature_id: number;
|
||||
@@ -122,10 +132,17 @@ export interface IssueComment {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plantilla de inspección. Desde v1.1 son un catálogo GLOBAL asignado a
|
||||
* proyectos vía pivot: `project_id` es informativo (puede ser null) y ya NO
|
||||
* determina visibilidad; `phase_id` se eliminó del contrato.
|
||||
* Cada item de `fields[]` puede incluir `group` (sección), `question`
|
||||
* (prompt corto) y `help` (ayuda larga), además de name/label/type/required/
|
||||
* options/min/max/step.
|
||||
*/
|
||||
export interface Template {
|
||||
id: number;
|
||||
project_id?: number;
|
||||
phase_id?: number | null;
|
||||
project_id?: number | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
fields?: unknown[];
|
||||
@@ -172,9 +189,10 @@ export interface Bundle {
|
||||
phases: Phase[];
|
||||
layers: Layer[];
|
||||
features: Feature[];
|
||||
feature_types?: FeatureType[];
|
||||
inspections: Inspection[];
|
||||
issues: Issue[];
|
||||
issue_tasks: IssueComment[] | IssueTask[];
|
||||
issue_tasks: IssueTask[];
|
||||
issue_comments: IssueComment[];
|
||||
templates: Template[];
|
||||
media: Media[];
|
||||
|
||||
@@ -28,6 +28,13 @@ async function openAndMigrate(): Promise<SQLite.SQLiteDatabase> {
|
||||
await db.execAsync('ALTER TABLE outbox ADD COLUMN local_id INTEGER');
|
||||
}
|
||||
|
||||
if (current < 3) {
|
||||
// API v1.1: feature gana feature_type_id + is_active. La tabla
|
||||
// feature_types la crea SCHEMA_SQL (CREATE TABLE IF NOT EXISTS).
|
||||
await db.execAsync('ALTER TABLE features ADD COLUMN feature_type_id INTEGER');
|
||||
await db.execAsync('ALTER TABLE features ADD COLUMN is_active INTEGER DEFAULT 1');
|
||||
}
|
||||
|
||||
if (current < SCHEMA_VERSION) {
|
||||
await db.execAsync(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
||||
}
|
||||
@@ -43,6 +50,7 @@ export async function wipeDatabase(): Promise<void> {
|
||||
'phases',
|
||||
'layers',
|
||||
'features',
|
||||
'feature_types',
|
||||
'inspections',
|
||||
'issues',
|
||||
'issue_tasks',
|
||||
|
||||
+25
-4
@@ -8,6 +8,7 @@ import {
|
||||
Bundle,
|
||||
DeletedTombstones,
|
||||
Feature,
|
||||
FeatureType,
|
||||
Inspection,
|
||||
Issue,
|
||||
IssueComment,
|
||||
@@ -102,6 +103,9 @@ export async function applyBundle(bundle: Bundle): Promise<void> {
|
||||
for (const f of bundle.features ?? []) {
|
||||
await upsertById(db, 'features', { ...pickFeature(f), project_id: pid });
|
||||
}
|
||||
for (const ft of bundle.feature_types ?? []) {
|
||||
await upsertById(db, 'feature_types', pickFeatureType(ft));
|
||||
}
|
||||
for (const i of bundle.inspections ?? []) {
|
||||
await upsertById(db, 'inspections', { ...pickInspection(i), project_id: pid });
|
||||
}
|
||||
@@ -202,9 +206,18 @@ const pickFeature = (f: Feature) => ({
|
||||
progress: f.progress ?? null,
|
||||
responsible: f.responsible ?? null,
|
||||
template_id: f.template_id ?? null,
|
||||
feature_type_id: f.feature_type_id ?? null,
|
||||
is_active: f.is_active == null ? 1 : Number(f.is_active),
|
||||
updated_at: f.updated_at,
|
||||
});
|
||||
|
||||
const pickFeatureType = (t: FeatureType) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
color: t.color ?? null,
|
||||
updated_at: t.updated_at ?? null,
|
||||
});
|
||||
|
||||
const pickInspection = (i: import('../api/types').Inspection) => ({
|
||||
id: i.id,
|
||||
feature_id: i.feature_id,
|
||||
@@ -258,7 +271,6 @@ const pickIssueComment = (c: IssueComment) => ({
|
||||
const pickTemplate = (t: Template) => ({
|
||||
id: t.id,
|
||||
project_id: t.project_id ?? null,
|
||||
phase_id: t.phase_id ?? null,
|
||||
name: t.name,
|
||||
description: t.description ?? null,
|
||||
fields: json(t.fields),
|
||||
@@ -380,11 +392,15 @@ export async function getIssueComments(issueId: number): Promise<IssueComment[]>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTemplates(projectId: number): Promise<Template[]> {
|
||||
/**
|
||||
* Plantillas locales. Desde API v1.1 son un catálogo global asignado por
|
||||
* pivot: el bundle solo trae las asignadas, así que aquí no se filtra por
|
||||
* project_id (es un campo informativo que puede ser null).
|
||||
*/
|
||||
export async function getTemplates(): Promise<Template[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.getAllAsync<Template & { fields: string | null }>(
|
||||
'SELECT * FROM templates WHERE project_id = ? OR project_id IS NULL ORDER BY name',
|
||||
projectId,
|
||||
'SELECT * FROM templates ORDER BY name',
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
@@ -392,6 +408,11 @@ export async function getTemplates(projectId: number): Promise<Template[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getFeatureTypes(): Promise<FeatureType[]> {
|
||||
const db = await getDb();
|
||||
return db.getAllAsync<FeatureType>('SELECT * FROM feature_types ORDER BY name');
|
||||
}
|
||||
|
||||
export async function getTemplate(id: number): Promise<Template | null> {
|
||||
const db = await getDb();
|
||||
const row = await db.getFirstAsync<Template & { fields: string | null }>(
|
||||
|
||||
+10
-1
@@ -6,7 +6,7 @@
|
||||
* Versionado simple por `user_version` de SQLite (ver database.ts).
|
||||
*/
|
||||
|
||||
export const SCHEMA_VERSION = 2;
|
||||
export const SCHEMA_VERSION = 3;
|
||||
|
||||
export const SCHEMA_SQL = `
|
||||
PRAGMA journal_mode = WAL;
|
||||
@@ -65,6 +65,15 @@ CREATE TABLE IF NOT EXISTS features (
|
||||
-- columnas locales para conciliar creaciones offline:
|
||||
local_uuid TEXT, -- uuid de la operación que la creó (si nació offline)
|
||||
dirty INTEGER DEFAULT 0 -- 1 = hay cambios locales sin confirmar
|
||||
-- v3 añade por migración: feature_type_id INTEGER, is_active INTEGER
|
||||
);
|
||||
|
||||
-- Catálogo global de tipos de feature (v1.1 de la API).
|
||||
CREATE TABLE IF NOT EXISTS feature_types (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
color TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inspections (
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Formulario de inspección generado dinámicamente desde los `fields` de una
|
||||
* plantilla. El esquema exacto de cada campo no está fijado en el contrato, así
|
||||
* que el renderer es tolerante: deduce key/label/type de varias formas posibles.
|
||||
* plantilla. El renderer es tolerante: deduce key/label/type de varias formas.
|
||||
*
|
||||
* API v1.1: cada campo puede traer `group` (sección de agrupación),
|
||||
* `question` (prompt corto mostrado como etiqueta principal), `help`
|
||||
* (texto de ayuda bajo el campo) y `required`.
|
||||
*/
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
|
||||
import { Alert, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
|
||||
import { Template } from '../api/types';
|
||||
import { getTemplate } from '../db/repositories';
|
||||
import { createInspection } from '../sync/mutations';
|
||||
@@ -26,12 +29,16 @@ interface NormField {
|
||||
label: string;
|
||||
type: 'text' | 'textarea' | 'number' | 'boolean' | 'select';
|
||||
options: string[];
|
||||
group: string;
|
||||
help: string | null;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
function normalizeField(raw: unknown, idx: number): NormField {
|
||||
const f = (raw ?? {}) as Record<string, unknown>;
|
||||
const key = String(f.key ?? f.name ?? f.id ?? `field_${idx}`);
|
||||
const label = String(f.label ?? f.name ?? f.key ?? key);
|
||||
// `question` (v1.1) tiene prioridad como etiqueta visible.
|
||||
const label = String(f.question ?? f.label ?? f.name ?? f.key ?? key);
|
||||
let type = String(f.type ?? 'text').toLowerCase();
|
||||
if (!['text', 'textarea', 'number', 'boolean', 'select'].includes(type)) {
|
||||
if (type === 'checkbox' || type === 'bool') type = 'boolean';
|
||||
@@ -43,7 +50,26 @@ function normalizeField(raw: unknown, idx: number): NormField {
|
||||
typeof o === 'string' ? o : String((o as Record<string, unknown>)?.value ?? o),
|
||||
)
|
||||
: [];
|
||||
return { key, label, type: type as NormField['type'], options };
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
type: type as NormField['type'],
|
||||
options,
|
||||
group: typeof f.group === 'string' ? f.group : '',
|
||||
help: typeof f.help === 'string' && f.help ? f.help : null,
|
||||
required: f.required === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Agrupa los campos por `group` preservando el orden de aparición. */
|
||||
function groupFields(fields: NormField[]): { group: string; items: NormField[] }[] {
|
||||
const out: { group: string; items: NormField[] }[] = [];
|
||||
for (const f of fields) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.group === f.group) last.items.push(f);
|
||||
else out.push({ group: f.group, items: [f] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const RESULTS = ['pass', 'fail', 'na'] as const;
|
||||
@@ -67,6 +93,20 @@ export function InspectionFormScreen({ route, navigation }: Props) {
|
||||
}, []);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
// Validación de campos obligatorios (los boolean cuentan siempre).
|
||||
const missing = fields.filter(
|
||||
(f) =>
|
||||
f.required &&
|
||||
f.type !== 'boolean' &&
|
||||
(values[f.key] == null || String(values[f.key]).trim() === ''),
|
||||
);
|
||||
if (missing.length) {
|
||||
Alert.alert(
|
||||
'Campos obligatorios',
|
||||
`Completa: ${missing.map((f) => f.label).join(', ')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await createInspection({
|
||||
@@ -81,47 +121,58 @@ export function InspectionFormScreen({ route, navigation }: Props) {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [featureId, templateId, values, result, notes, navigation]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [featureId, templateId, values, result, notes, navigation, template]);
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
<Text style={styles.subtitle}>{featureName}</Text>
|
||||
<Text style={styles.tplName}>{template?.name ?? 'Inspección libre'}</Text>
|
||||
|
||||
{fields.map((f) => {
|
||||
const renderField = (f: NormField) => {
|
||||
const label = f.required ? `${f.label} *` : f.label;
|
||||
let control: React.ReactNode;
|
||||
if (f.type === 'boolean') {
|
||||
return (
|
||||
<View key={f.key} style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>{f.label}</Text>
|
||||
<Switch
|
||||
value={!!values[f.key]}
|
||||
onValueChange={(v) => setValue(f.key, v)}
|
||||
/>
|
||||
control = (
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>{label}</Text>
|
||||
<Switch value={!!values[f.key]} onValueChange={(v) => setValue(f.key, v)} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (f.type === 'select' && f.options.length) {
|
||||
return (
|
||||
} else if (f.type === 'select' && f.options.length) {
|
||||
control = (
|
||||
<ChipSelect
|
||||
key={f.key}
|
||||
label={f.label}
|
||||
label={label}
|
||||
value={values[f.key] as string | undefined}
|
||||
options={f.options}
|
||||
onChange={(v) => setValue(f.key, v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
} else {
|
||||
control = (
|
||||
<Field
|
||||
key={f.key}
|
||||
label={f.label}
|
||||
label={label}
|
||||
value={values[f.key] != null ? String(values[f.key]) : ''}
|
||||
onChangeText={(t) => setValue(f.key, f.type === 'number' ? Number(t) : t)}
|
||||
keyboardType={f.type === 'number' ? 'numeric' : 'default'}
|
||||
multiline={f.type === 'textarea'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
}
|
||||
return (
|
||||
<View key={f.key}>
|
||||
{control}
|
||||
{f.help ? <Text style={styles.help}>{f.help}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
<Text style={styles.subtitle}>{featureName}</Text>
|
||||
<Text style={styles.tplName}>{template?.name ?? 'Inspección libre'}</Text>
|
||||
|
||||
{groupFields(fields).map((section, si) => (
|
||||
<View key={`g${si}`}>
|
||||
{section.group ? <SectionTitle>{section.group}</SectionTitle> : null}
|
||||
{section.items.map(renderField)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<SectionTitle>Resultado</SectionTitle>
|
||||
<Card style={{ marginBottom: 12 }}>
|
||||
@@ -153,4 +204,5 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 10,
|
||||
},
|
||||
switchLabel: { fontSize: 15, flex: 1 },
|
||||
help: { fontSize: 12, color: COLORS.muted, marginTop: -6, marginBottom: 8 },
|
||||
});
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Feature, Inspection } from '../../api/types';
|
||||
import { ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Feature, FeatureType, Inspection } from '../../api/types';
|
||||
import { hasPermission, useSession } from '../../auth/session';
|
||||
import { getFeature, getInspectionsByFeature } from '../../db/repositories';
|
||||
import { getFeature, getFeatureTypes, getInspectionsByFeature } from '../../db/repositories';
|
||||
import { updateFeature } from '../../sync/mutations';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import {
|
||||
@@ -35,14 +35,17 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
|
||||
|
||||
const [feature, setFeature] = useState<Feature | null>(null);
|
||||
const [inspections, setInspections] = useState<Inspection[]>([]);
|
||||
const [featureTypes, setFeatureTypes] = useState<FeatureType[]>([]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [f, ins] = await Promise.all([
|
||||
const [f, ins, types] = await Promise.all([
|
||||
getFeature(featureId),
|
||||
getInspectionsByFeature(featureId),
|
||||
getFeatureTypes(),
|
||||
]);
|
||||
setFeature(f);
|
||||
setInspections(ins);
|
||||
setFeatureTypes(types);
|
||||
}, [featureId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,6 +68,14 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
|
||||
[featureId, refresh],
|
||||
);
|
||||
|
||||
const onToggleActive = useCallback(
|
||||
async (active: boolean) => {
|
||||
await updateFeature({ id: featureId, is_active: active });
|
||||
await refresh();
|
||||
},
|
||||
[featureId, refresh],
|
||||
);
|
||||
|
||||
if (!feature) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
@@ -73,12 +84,19 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
const featureType = featureTypes.find((t) => t.id === feature.feature_type_id);
|
||||
const isActive = feature.is_active == null || Number(feature.is_active) !== 0;
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
<Text style={styles.title}>{feature.name}</Text>
|
||||
<View style={styles.badges}>
|
||||
{featureType && (
|
||||
<Badge label={featureType.name} color={featureType.color ?? COLORS.muted} />
|
||||
)}
|
||||
{feature.status && <Badge label={feature.status} color={COLORS.primary} />}
|
||||
<Badge label={`${Math.round(feature.progress ?? 0)}%`} color={COLORS.muted} />
|
||||
{!isActive && <Badge label="inactiva" color={COLORS.danger} />}
|
||||
</View>
|
||||
|
||||
<MediaStrip
|
||||
@@ -110,6 +128,14 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.activeRow}>
|
||||
<Text style={styles.fieldLabel}>Activa</Text>
|
||||
<Switch
|
||||
value={isActive}
|
||||
onValueChange={(v) => void onToggleActive(v)}
|
||||
trackColor={{ true: COLORS.primary }}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -149,6 +175,12 @@ const styles = StyleSheet.create({
|
||||
editCard: { marginTop: 12 },
|
||||
fieldLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 6, fontWeight: '600' },
|
||||
progressRow: { flexDirection: 'row', gap: 6, flexWrap: 'wrap' },
|
||||
activeRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
pBtn: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
|
||||
@@ -34,6 +34,8 @@ export async function updateFeature(input: {
|
||||
status?: string;
|
||||
progress?: number;
|
||||
responsible?: string;
|
||||
is_active?: boolean;
|
||||
feature_type_id?: number;
|
||||
}): Promise<string> {
|
||||
const db = await getDb();
|
||||
const sets: string[] = [];
|
||||
@@ -50,6 +52,14 @@ export async function updateFeature(input: {
|
||||
sets.push('responsible = ?');
|
||||
params.push(input.responsible);
|
||||
}
|
||||
if (input.is_active !== undefined) {
|
||||
sets.push('is_active = ?');
|
||||
params.push(input.is_active ? 1 : 0);
|
||||
}
|
||||
if (input.feature_type_id !== undefined) {
|
||||
sets.push('feature_type_id = ?');
|
||||
params.push(input.feature_type_id);
|
||||
}
|
||||
if (sets.length) {
|
||||
params.push(input.id);
|
||||
await db.runAsync(
|
||||
|
||||
Reference in New Issue
Block a user