From 9bcc51e3b28dfe04d9d883c8bc5cef981a40fff2 Mon Sep 17 00:00:00 2001 From: javier Date: Thu, 18 Jun 2026 18:30:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20app=20de=20campo=20m=C3=B3vil+?= =?UTF-8?q?tablet=20(UI,=20mapa,=20fotos,=20auto-sync,=20reconciliaci?= =?UTF-8?q?=C3=B3n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 0: config Android (package, permisos, orientación), eas.json (APK), deps nativas (react-native-maps, image-picker, location), app.config.js para la key de Google Maps por secreto EAS. Fase 1: capa responsive (useLayout) + componente MasterDetail (dos paneles en tablet, navegación en móvil). Fase 2: pantallas funcionales — detalle de proyecto con secciones Fases/Features/ Incidencias, edición de progreso/estado, formulario de inspección dinámico desde plantilla, incidencias maestro-detalle (checklist + comentarios), alta de incidencia; gating por permisos Spatie. Fase 3: fotos (cámara/galería) → cola de media, con miniaturas pendientes/sync. Fase 4: mapa de features (Google Maps) con geometría GeoJSON y selección. Fase 5: auto-sync (foreground/reconexión/intervalo, con candado) + reconciliación de creaciones offline (id temporal negativo → server_id, remapeo de FKs hijas). Fase 6: revisión de conflictos/errores del outbox, indicadores y APK preview. Co-Authored-By: Claude Opus 4.8 --- README.md | 43 +++- app.config.js | 14 ++ app.json | 26 ++- eas.json | 29 +++ package-lock.json | 83 ++++++- package.json | 4 + src/components/SyncStatusBar.tsx | 20 +- src/config.ts | 10 + src/db/database.ts | 9 +- src/db/outbox.ts | 79 ++++++- src/db/repositories.ts | 185 ++++++++++++++++ src/db/schema.ts | 2 +- src/navigation/index.tsx | 30 +++ src/navigation/types.ts | 5 + src/screens/FeatureDetailScreen.tsx | 10 + src/screens/InspectionFormScreen.tsx | 156 +++++++++++++ src/screens/IssueCreateScreen.tsx | 69 ++++++ src/screens/IssueDetailScreen.tsx | 10 + src/screens/OutboxScreen.tsx | 80 +++++++ src/screens/ProjectDetailScreen.tsx | 151 ++++++------- src/screens/ProjectsScreen.tsx | 3 +- src/screens/detail/FeatureDetailContent.tsx | 166 ++++++++++++++ src/screens/detail/IssueDetailContent.tsx | 233 ++++++++++++++++++++ src/screens/sections/FeaturesSection.tsx | 115 ++++++++++ src/screens/sections/IssuesSection.tsx | 107 +++++++++ src/screens/sections/PhasesSection.tsx | 93 ++++++++ src/sync/engine.ts | 136 +++++++++--- src/sync/mutations.ts | 63 +++++- src/sync/useAutoSync.ts | 60 +++++ src/sync/uuid.ts | 13 ++ src/ui/FeatureMap.tsx | 145 ++++++++++++ src/ui/MasterDetail.tsx | 67 ++++++ src/ui/MediaStrip.tsx | 126 +++++++++++ src/ui/components.tsx | 191 ++++++++++++++++ src/ui/geojson.ts | 92 ++++++++ src/ui/responsive.ts | 38 ++++ 36 files changed, 2532 insertions(+), 131 deletions(-) create mode 100644 app.config.js create mode 100644 eas.json create mode 100644 src/screens/FeatureDetailScreen.tsx create mode 100644 src/screens/InspectionFormScreen.tsx create mode 100644 src/screens/IssueCreateScreen.tsx create mode 100644 src/screens/IssueDetailScreen.tsx create mode 100644 src/screens/OutboxScreen.tsx create mode 100644 src/screens/detail/FeatureDetailContent.tsx create mode 100644 src/screens/detail/IssueDetailContent.tsx create mode 100644 src/screens/sections/FeaturesSection.tsx create mode 100644 src/screens/sections/IssuesSection.tsx create mode 100644 src/screens/sections/PhasesSection.tsx create mode 100644 src/sync/useAutoSync.ts create mode 100644 src/ui/FeatureMap.tsx create mode 100644 src/ui/MasterDetail.tsx create mode 100644 src/ui/MediaStrip.tsx create mode 100644 src/ui/components.tsx create mode 100644 src/ui/geojson.ts create mode 100644 src/ui/responsive.ts diff --git a/README.md b/README.md index e296391..1482403 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,55 @@ Contrato de la API: [`docs/openapi.yaml`](docs/openapi.yaml) · brief: [`docs/MOBILE_APP_BRIEF.md`](docs/MOBILE_APP_BRIEF.md) · protocolo de sync: [`docs/MOBILE_SYNC_PROTOCOL.md`](docs/MOBILE_SYNC_PROTOCOL.md). -## Arranque +## Arranque (Android) + +La app usa módulos nativos (expo-sqlite, expo-secure-store, react-native-maps) que **no +funcionan en Expo Go**: hay que usar un *development build* o un APK. ```bash npm install -npm start # abre Expo; pulsa 'a' (Android) / 'i' (iOS) / 'w' (web) -npm run typecheck # comprobación de tipos +npm run typecheck # comprobación de tipos + +# Development build (emulador o dispositivo con depuración USB): +npx expo run:android # compila e instala un dev client con hot reload ``` > **Backend local (XAMPP):** ajusta `BASE_URL` en [`src/config.ts`](src/config.ts). > Desde emulador Android usa `http://10.0.2.2/...`; desde dispositivo físico, la IP LAN del PC. > `localhost` apunta al propio teléfono, no al PC. +### APK para repartir (sideload) + +Con [EAS Build](https://docs.expo.dev/build/introduction/) (nube de Expo, no necesita Mac): + +```bash +npm i -g eas-cli # una vez +eas login # cuenta Expo +eas build -p android --profile preview # genera un .apk (distribution: internal) +``` + +Al terminar, EAS da un enlace de descarga del `.apk`; instálalo en el dispositivo +(habilitando "orígenes desconocidos"). Perfiles en [`eas.json`](eas.json): +`development` (dev client), `preview` (APK interno), `production` (AAB, para Play más adelante). + +### Mapa (Google Maps) + +La sección Features incluye un mapa (react-native-maps) que dibuja la geometría GeoJSON. +Requiere una **API key de Google Maps (Android)**, que se inyecta vía variable de entorno +`GOOGLE_MAPS_API_KEY` en [`app.config.js`](app.config.js) — **no se commitea**: + +```bash +# local +export GOOGLE_MAPS_API_KEY=AIza... # (PowerShell: $env:GOOGLE_MAPS_API_KEY="AIza...") +npx expo run:android + +# EAS: guárdala como secreto +eas secret:create --name GOOGLE_MAPS_API_KEY --value AIza... +``` + +Sin key, la app funciona pero el mapa no carga tiles (la lista de features sí). Las tiles +necesitan conexión; la geometría se dibuja también sin red. + ## Arquitectura ``` diff --git a/app.config.js b/app.config.js new file mode 100644 index 0000000..11ff6cc --- /dev/null +++ b/app.config.js @@ -0,0 +1,14 @@ +// Config dinámica: toma app.json como base e inyecta la API key de Google Maps +// desde la variable de entorno GOOGLE_MAPS_API_KEY (secreto de EAS), para no +// commitearla. En local puedes exportarla antes de `npx expo run:android`. +module.exports = ({ config }) => { + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + if (apiKey) { + config.android = config.android || {}; + config.android.config = { + ...(config.android.config || {}), + googleMaps: { apiKey }, + }; + } + return config; +}; diff --git a/app.json b/app.json index 552efb7..e05b765 100644 --- a/app.json +++ b/app.json @@ -3,13 +3,21 @@ "name": "Avante", "slug": "avante-movil", "version": "1.0.0", - "orientation": "portrait", + "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "light", "ios": { "supportsTablet": true }, "android": { + "package": "group.mai.avante", + "versionCode": 1, + "permissions": [ + "android.permission.INTERNET", + "android.permission.CAMERA", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION" + ], "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/android-icon-foreground.png", @@ -23,7 +31,21 @@ }, "plugins": [ "expo-sqlite", - "expo-secure-store" + "expo-secure-store", + "expo-build-properties", + [ + "expo-image-picker", + { + "photosPermission": "Avante usa tus fotos para adjuntarlas a incidencias e inspecciones.", + "cameraPermission": "Avante usa la cámara para fotografiar el avance de obra e incidencias." + } + ], + [ + "expo-location", + { + "locationWhenInUsePermission": "Avante usa tu ubicación para situar avances e incidencias en el mapa." + } + ] ] } } diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..df8c051 --- /dev/null +++ b/eas.json @@ -0,0 +1,29 @@ +{ + "cli": { + "version": ">= 12.0.0", + "appVersionSource": "local" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "preview": { + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "production": { + "android": { + "buildType": "app-bundle" + } + } + }, + "submit": { + "production": {} + } +} diff --git a/package-lock.json b/package-lock.json index 56c2a43..4add743 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,28 @@ { - "name": "avante_init", + "name": "avante-movil", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "avante_init", + "name": "avante-movil", "version": "1.0.0", "dependencies": { "@react-native-community/netinfo": "12.0.1", "@react-navigation/native": "^7.3.3", "@react-navigation/native-stack": "^7.17.5", "expo": "~56.0.12", + "expo-build-properties": "~56.0.19", "expo-crypto": "~56.0.4", "expo-file-system": "~56.0.8", + "expo-image-picker": "~56.0.18", + "expo-location": "~56.0.18", "expo-secure-store": "~56.0.4", "expo-sqlite": "~56.0.5", "expo-status-bar": "~56.0.4", "react": "19.2.3", "react-native": "0.85.3", + "react-native-maps": "1.27.2", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2" }, @@ -1790,6 +1794,12 @@ "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2777,6 +2787,20 @@ } } }, + "node_modules/expo-build-properties": { + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.19.tgz", + "integrity": "sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^56.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-crypto": { "version": "56.0.4", "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz", @@ -2796,6 +2820,39 @@ "react-native": "*" } }, + "node_modules/expo-image-loader": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-56.0.3.tgz", + "integrity": "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.18.tgz", + "integrity": "sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~56.0.3" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-location": { + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.18.tgz", + "integrity": "sha512-6xP0UwGy8a7EEHAMeigYAp3HNo3yWHAg05tVPUfwrOWepWPpFXmjsfUBUxQdkpfpjddJ9r+f4PplxZqKI0LtjA==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.10.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "56.0.16", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.16.tgz", @@ -5248,6 +5305,28 @@ } } }, + "node_modules/react-native-maps": { + "version": "1.27.2", + "resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.27.2.tgz", + "integrity": "sha512-VKr+xZ2RZGHHJlY6KhlafvGSmK0dq/tUu5uhfJ7K9rwN5pUdubdugzMKGDU/16lXmQSg7xbClKhRctj3Pm5F5g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.13" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "react": ">= 18.3.1", + "react-native": ">= 0.76.0", + "react-native-web": ">= 0.11" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/react-native-safe-area-context": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", diff --git a/package.json b/package.json index 39ba267..9c14434 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,17 @@ "@react-navigation/native": "^7.3.3", "@react-navigation/native-stack": "^7.17.5", "expo": "~56.0.12", + "expo-build-properties": "~56.0.19", "expo-crypto": "~56.0.4", "expo-file-system": "~56.0.8", + "expo-image-picker": "~56.0.18", + "expo-location": "~56.0.18", "expo-secure-store": "~56.0.4", "expo-sqlite": "~56.0.5", "expo-status-bar": "~56.0.4", "react": "19.2.3", "react-native": "0.85.3", + "react-native-maps": "1.27.2", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2" }, diff --git a/src/components/SyncStatusBar.tsx b/src/components/SyncStatusBar.tsx index f438479..fe119fd 100644 --- a/src/components/SyncStatusBar.tsx +++ b/src/components/SyncStatusBar.tsx @@ -1,19 +1,22 @@ import React from 'react'; -import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { OutboxCounts } from '../db/outbox'; import { useIsOnline } from '../net/connectivity'; export function SyncStatusBar({ counts, syncing, + onPress, }: { counts: OutboxCounts; syncing: boolean; + onPress?: () => void; }) { const online = useIsOnline(); const pendientes = counts.pending + counts.mediaPending; + const hasProblems = counts.conflict > 0 || counts.error > 0; - return ( + const content = ( {syncing && } @@ -22,8 +25,18 @@ export function SyncStatusBar({ {counts.conflict > 0 ? ` · ${counts.conflict} conflicto(s)` : ''} {counts.error > 0 ? ` · ${counts.error} error(es)` : ''} + {hasProblems && onPress ? revisar › : null} ); + + if (onPress) { + return ( + + {content} + + ); + } + return content; } const styles = StyleSheet.create({ @@ -34,5 +47,6 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, paddingVertical: 8, }, - text: { color: '#fff', fontSize: 13, fontWeight: '600' }, + text: { color: '#fff', fontSize: 13, fontWeight: '600', flex: 1 }, + chevron: { color: '#fff', fontSize: 12, fontWeight: '700', textDecorationLine: 'underline' }, }); diff --git a/src/config.ts b/src/config.ts index 2c2d342..769efd4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,16 @@ const DEV_HOST = 'http://10.0.2.2/construprogress/public'; export const BASE_URL = `${DEV_HOST}/api/v1`; +/** Raíz del host (para resolver URLs relativas de media, p.ej. `/storage/...`). */ +export const ORIGIN = DEV_HOST; + +/** Convierte una url de media (posiblemente relativa) en absoluta. */ +export function absoluteUrl(url?: string | null): string | undefined { + if (!url) return undefined; + if (/^https?:\/\//i.test(url)) return url; + return `${ORIGIN}${url.startsWith('/') ? '' : '/'}${url}`; +} + /** Se envía en cada petición como cabecera X-App-Version (ver protocolo §7). */ export const APP_VERSION = '1.0.0'; diff --git a/src/db/database.ts b/src/db/database.ts index c81948f..ee5bc39 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -18,9 +18,16 @@ async function openAndMigrate(): Promise { const db = await SQLite.openDatabaseAsync(DB_NAME); await db.execAsync(SCHEMA_SQL); - // Versionado por PRAGMA user_version (migraciones futuras van aquí). + // Versionado por PRAGMA user_version. Migraciones incrementales. const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); const current = row?.user_version ?? 0; + + if (current < 2) { + // local_id: id temporal de la fila creada offline que produjo esta operación + // (para reconciliar el id del servidor tras el PUSH). + await db.execAsync('ALTER TABLE outbox ADD COLUMN local_id INTEGER'); + } + if (current < SCHEMA_VERSION) { await db.execAsync(`PRAGMA user_version = ${SCHEMA_VERSION}`); } diff --git a/src/db/outbox.ts b/src/db/outbox.ts index b4b1fb4..a1e3e17 100644 --- a/src/db/outbox.ts +++ b/src/db/outbox.ts @@ -20,19 +20,21 @@ export async function enqueueOperation( entity: SyncEntity, op: SyncOp, data: Record, + opts?: { uuid?: string; localId?: number }, ): Promise { const db = await getDb(); - const uuid = newUuid(); + const uuid = opts?.uuid ?? newUuid(); const clientUpdatedAt = nowIso(); await db.runAsync( - `INSERT INTO outbox (uuid, entity, op, data, client_updated_at, status, attempts, created_at) - VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`, + `INSERT INTO outbox (uuid, entity, op, data, client_updated_at, status, attempts, created_at, local_id) + VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`, uuid, entity, op, JSON.stringify(data), clientUpdatedAt, clientUpdatedAt, + opts?.localId ?? null, ); return uuid; } @@ -49,10 +51,17 @@ interface RawOutbox { server_payload: string | null; attempts: number; created_at: string; + local_id: number | null; +} + +/** Operación pendiente enriquecida con su id local temporal (si la creó offline). */ +export interface PendingOp extends Operation { + /** id temporal (negativo) de la fila local que produjo esta operación, o null. */ + localId: number | null; } /** Operaciones pendientes de enviar, en orden de creación. */ -export async function getPendingOperations(limit = 100): Promise { +export async function getPendingOutbox(limit = 200): Promise { const db = await getDb(); const rows = await db.getAllAsync( `SELECT * FROM outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`, @@ -64,9 +73,19 @@ export async function getPendingOperations(limit = 100): Promise { uuid: r.uuid, client_updated_at: r.client_updated_at, data: JSON.parse(r.data) as Record, + localId: r.local_id, })); } +/** Reescribe el `data` de una operación pendiente (tras remapear ids temporales). */ +export async function updateOutboxData( + uuid: string, + data: Record, +): Promise { + const db = await getDb(); + await db.runAsync('UPDATE outbox SET data = ? WHERE uuid = ?', JSON.stringify(data), uuid); +} + /** Aplica el resultado de /sync de una operación a su fila del outbox. */ export async function applyOperationResult(result: OperationResult): Promise { const db = await getDb(); @@ -97,6 +116,41 @@ export async function purgeSentOperations(): Promise { await db.runAsync(`DELETE FROM outbox WHERE status = 'sent'`); } +export interface ProblemOp { + uuid: string; + entity: SyncEntity; + op: SyncOp; + status: 'conflict' | 'error'; + error: string | null; + server_payload: string | null; + data: string; + created_at: string; +} + +/** Operaciones que requieren atención (conflicto o error). */ +export async function getProblemOps(): Promise { + const db = await getDb(); + return db.getAllAsync( + `SELECT uuid, entity, op, status, error, server_payload, data, created_at + FROM outbox WHERE status IN ('conflict', 'error') ORDER BY created_at DESC`, + ); +} + +/** Reintentar: vuelve a poner la operación como pendiente. */ +export async function retryOp(uuid: string): Promise { + const db = await getDb(); + await db.runAsync( + `UPDATE outbox SET status = 'pending', error = NULL, server_payload = NULL WHERE uuid = ?`, + uuid, + ); +} + +/** Descartar: elimina la operación del outbox. */ +export async function discardOp(uuid: string): Promise { + const db = await getDb(); + await db.runAsync('DELETE FROM outbox WHERE uuid = ?', uuid); +} + export interface OutboxCounts { pending: number; conflict: number; @@ -173,12 +227,27 @@ export interface MediaOutboxRow { export async function getPendingMedia(limit = 50): Promise { const db = await getDb(); + // parent_id > 0: el padre ya tiene id de servidor (los creados offline aún + // tienen id temporal negativo; esperan a reconciliarse). return db.getAllAsync( - `SELECT * FROM media_outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`, + `SELECT * FROM media_outbox WHERE status = 'pending' AND parent_id > 0 ORDER BY created_at ASC LIMIT ?`, limit, ); } +/** Media local (pendiente o con error) de un padre, para previsualizar en la UI. */ +export async function getMediaOutboxFor( + parentEntity: string, + parentId: number, +): Promise { + const db = await getDb(); + return db.getAllAsync( + `SELECT * FROM media_outbox WHERE parent_entity = ? AND parent_id = ? AND status != 'sent' ORDER BY created_at DESC`, + parentEntity, + parentId, + ); +} + export async function markMediaSent(uuid: string, mediaId: number | null): Promise { const db = await getDb(); await db.runAsync( diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 08d7aff..23dcfb4 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -8,10 +8,12 @@ import { Bundle, DeletedTombstones, Feature, + Inspection, Issue, IssueComment, IssueTask, Layer, + Media, Phase, Project, Template, @@ -298,6 +300,117 @@ export async function getIssues(projectId: number): Promise { ); } +export async function getIssue(id: number): Promise { + const db = await getDb(); + return db.getFirstAsync('SELECT * FROM issues WHERE id = ?', id); +} + +export async function getLayers(projectId: number): Promise { + const db = await getDb(); + return db.getAllAsync( + 'SELECT * FROM layers WHERE project_id = ? ORDER BY name', + projectId, + ); +} + +/** Features de un proyecto. `geometry` se devuelve como objeto ya parseado. */ +export async function getFeatures(projectId: number): Promise { + const db = await getDb(); + const rows = await db.getAllAsync( + 'SELECT * FROM features WHERE project_id = ? ORDER BY layer_id, name', + projectId, + ); + return rows.map(parseGeometry); +} + +export async function getFeature(id: number): Promise { + const db = await getDb(); + const row = await db.getFirstAsync( + 'SELECT * FROM features WHERE id = ?', + id, + ); + return row ? parseGeometry(row) : null; +} + +function parseGeometry(row: Feature & { geometry: string | null }): Feature { + let geometry: unknown = null; + if (row.geometry && typeof row.geometry === 'string') { + try { + geometry = JSON.parse(row.geometry); + } catch { + geometry = null; + } + } + return { ...row, geometry }; +} + +export async function getInspectionsByFeature(featureId: number): Promise { + const db = await getDb(); + const rows = await db.getAllAsync( + 'SELECT * FROM inspections WHERE feature_id = ? ORDER BY created_at DESC, id DESC', + featureId, + ); + return rows.map((r) => ({ + ...r, + data: r.data ? (JSON.parse(r.data) as Record) : undefined, + })); +} + +type IssueTaskRow = Omit & { is_done: number }; + +export async function getIssueTasks(issueId: number): Promise { + const db = await getDb(); + const rows = await db.getAllAsync( + 'SELECT * FROM issue_tasks WHERE issue_id = ? ORDER BY "order", id', + issueId, + ); + return rows.map((r) => ({ ...r, is_done: !!r.is_done })); +} + +export async function getIssueComments(issueId: number): Promise { + const db = await getDb(); + return db.getAllAsync( + 'SELECT * FROM issue_comments WHERE issue_id = ? ORDER BY created_at, id', + issueId, + ); +} + +export async function getTemplates(projectId: number): Promise { + const db = await getDb(); + const rows = await db.getAllAsync