feat(android): app de campo móvil+tablet (UI, mapa, fotos, auto-sync, reconciliación)
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4e9c7d059f
commit
9bcc51e3b2
@@ -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) ·
|
brief: [`docs/MOBILE_APP_BRIEF.md`](docs/MOBILE_APP_BRIEF.md) ·
|
||||||
protocolo de sync: [`docs/MOBILE_SYNC_PROTOCOL.md`](docs/MOBILE_SYNC_PROTOCOL.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
|
```bash
|
||||||
npm install
|
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).
|
> **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.
|
> 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.
|
> `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
|
## Arquitectura
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -3,13 +3,21 @@
|
|||||||
"name": "Avante",
|
"name": "Avante",
|
||||||
"slug": "avante-movil",
|
"slug": "avante-movil",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true
|
||||||
},
|
},
|
||||||
"android": {
|
"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": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#E6F4FE",
|
||||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
@@ -23,7 +31,21 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-sqlite",
|
"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."
|
||||||
|
}
|
||||||
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+81
-2
@@ -1,24 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "avante_init",
|
"name": "avante-movil",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "avante_init",
|
"name": "avante-movil",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-community/netinfo": "12.0.1",
|
"@react-native-community/netinfo": "12.0.1",
|
||||||
"@react-navigation/native": "^7.3.3",
|
"@react-navigation/native": "^7.3.3",
|
||||||
"@react-navigation/native-stack": "^7.17.5",
|
"@react-navigation/native-stack": "^7.17.5",
|
||||||
"expo": "~56.0.12",
|
"expo": "~56.0.12",
|
||||||
|
"expo-build-properties": "~56.0.19",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
|
"expo-image-picker": "~56.0.18",
|
||||||
|
"expo-location": "~56.0.18",
|
||||||
"expo-secure-store": "~56.0.4",
|
"expo-secure-store": "~56.0.4",
|
||||||
"expo-sqlite": "~56.0.5",
|
"expo-sqlite": "~56.0.5",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
|
"react-native-maps": "1.27.2",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2"
|
"react-native-screens": "4.25.2"
|
||||||
},
|
},
|
||||||
@@ -1790,6 +1794,12 @@
|
|||||||
"integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
|
"integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"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": {
|
"node_modules/expo-crypto": {
|
||||||
"version": "56.0.4",
|
"version": "56.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz",
|
||||||
@@ -2796,6 +2820,39 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "56.0.16",
|
"version": "56.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.16.tgz",
|
"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": {
|
"node_modules/react-native-safe-area-context": {
|
||||||
"version": "5.7.0",
|
"version": "5.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
|
||||||
|
|||||||
@@ -7,13 +7,17 @@
|
|||||||
"@react-navigation/native": "^7.3.3",
|
"@react-navigation/native": "^7.3.3",
|
||||||
"@react-navigation/native-stack": "^7.17.5",
|
"@react-navigation/native-stack": "^7.17.5",
|
||||||
"expo": "~56.0.12",
|
"expo": "~56.0.12",
|
||||||
|
"expo-build-properties": "~56.0.19",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
|
"expo-image-picker": "~56.0.18",
|
||||||
|
"expo-location": "~56.0.18",
|
||||||
"expo-secure-store": "~56.0.4",
|
"expo-secure-store": "~56.0.4",
|
||||||
"expo-sqlite": "~56.0.5",
|
"expo-sqlite": "~56.0.5",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
|
"react-native-maps": "1.27.2",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2"
|
"react-native-screens": "4.25.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import React from 'react';
|
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 { OutboxCounts } from '../db/outbox';
|
||||||
import { useIsOnline } from '../net/connectivity';
|
import { useIsOnline } from '../net/connectivity';
|
||||||
|
|
||||||
export function SyncStatusBar({
|
export function SyncStatusBar({
|
||||||
counts,
|
counts,
|
||||||
syncing,
|
syncing,
|
||||||
|
onPress,
|
||||||
}: {
|
}: {
|
||||||
counts: OutboxCounts;
|
counts: OutboxCounts;
|
||||||
syncing: boolean;
|
syncing: boolean;
|
||||||
|
onPress?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const online = useIsOnline();
|
const online = useIsOnline();
|
||||||
const pendientes = counts.pending + counts.mediaPending;
|
const pendientes = counts.pending + counts.mediaPending;
|
||||||
|
const hasProblems = counts.conflict > 0 || counts.error > 0;
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<View style={[styles.bar, { backgroundColor: online ? '#1f6f43' : '#8a6d00' }]}>
|
<View style={[styles.bar, { backgroundColor: online ? '#1f6f43' : '#8a6d00' }]}>
|
||||||
{syncing && <ActivityIndicator color="#fff" size="small" />}
|
{syncing && <ActivityIndicator color="#fff" size="small" />}
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
@@ -22,8 +25,18 @@ export function SyncStatusBar({
|
|||||||
{counts.conflict > 0 ? ` · ${counts.conflict} conflicto(s)` : ''}
|
{counts.conflict > 0 ? ` · ${counts.conflict} conflicto(s)` : ''}
|
||||||
{counts.error > 0 ? ` · ${counts.error} error(es)` : ''}
|
{counts.error > 0 ? ` · ${counts.error} error(es)` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
|
{hasProblems && onPress ? <Text style={styles.chevron}>revisar ›</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onPress) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity activeOpacity={0.8} onPress={onPress}>
|
||||||
|
{content}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
@@ -34,5 +47,6 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 8,
|
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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ const DEV_HOST = 'http://10.0.2.2/construprogress/public';
|
|||||||
|
|
||||||
export const BASE_URL = `${DEV_HOST}/api/v1`;
|
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). */
|
/** Se envía en cada petición como cabecera X-App-Version (ver protocolo §7). */
|
||||||
export const APP_VERSION = '1.0.0';
|
export const APP_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -18,9 +18,16 @@ async function openAndMigrate(): Promise<SQLite.SQLiteDatabase> {
|
|||||||
const db = await SQLite.openDatabaseAsync(DB_NAME);
|
const db = await SQLite.openDatabaseAsync(DB_NAME);
|
||||||
await db.execAsync(SCHEMA_SQL);
|
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 row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
|
||||||
const current = row?.user_version ?? 0;
|
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) {
|
if (current < SCHEMA_VERSION) {
|
||||||
await db.execAsync(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
await db.execAsync(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-5
@@ -20,19 +20,21 @@ export async function enqueueOperation(
|
|||||||
entity: SyncEntity,
|
entity: SyncEntity,
|
||||||
op: SyncOp,
|
op: SyncOp,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
|
opts?: { uuid?: string; localId?: number },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const uuid = newUuid();
|
const uuid = opts?.uuid ?? newUuid();
|
||||||
const clientUpdatedAt = nowIso();
|
const clientUpdatedAt = nowIso();
|
||||||
await db.runAsync(
|
await db.runAsync(
|
||||||
`INSERT INTO outbox (uuid, entity, op, data, client_updated_at, status, attempts, created_at)
|
`INSERT INTO outbox (uuid, entity, op, data, client_updated_at, status, attempts, created_at, local_id)
|
||||||
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`,
|
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
|
||||||
uuid,
|
uuid,
|
||||||
entity,
|
entity,
|
||||||
op,
|
op,
|
||||||
JSON.stringify(data),
|
JSON.stringify(data),
|
||||||
clientUpdatedAt,
|
clientUpdatedAt,
|
||||||
clientUpdatedAt,
|
clientUpdatedAt,
|
||||||
|
opts?.localId ?? null,
|
||||||
);
|
);
|
||||||
return uuid;
|
return uuid;
|
||||||
}
|
}
|
||||||
@@ -49,10 +51,17 @@ interface RawOutbox {
|
|||||||
server_payload: string | null;
|
server_payload: string | null;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
created_at: string;
|
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. */
|
/** Operaciones pendientes de enviar, en orden de creación. */
|
||||||
export async function getPendingOperations(limit = 100): Promise<Operation[]> {
|
export async function getPendingOutbox(limit = 200): Promise<PendingOp[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.getAllAsync<RawOutbox>(
|
const rows = await db.getAllAsync<RawOutbox>(
|
||||||
`SELECT * FROM outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`,
|
`SELECT * FROM outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`,
|
||||||
@@ -64,9 +73,19 @@ export async function getPendingOperations(limit = 100): Promise<Operation[]> {
|
|||||||
uuid: r.uuid,
|
uuid: r.uuid,
|
||||||
client_updated_at: r.client_updated_at,
|
client_updated_at: r.client_updated_at,
|
||||||
data: JSON.parse(r.data) as Record<string, unknown>,
|
data: JSON.parse(r.data) as Record<string, unknown>,
|
||||||
|
localId: r.local_id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reescribe el `data` de una operación pendiente (tras remapear ids temporales). */
|
||||||
|
export async function updateOutboxData(
|
||||||
|
uuid: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
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. */
|
/** Aplica el resultado de /sync de una operación a su fila del outbox. */
|
||||||
export async function applyOperationResult(result: OperationResult): Promise<void> {
|
export async function applyOperationResult(result: OperationResult): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
@@ -97,6 +116,41 @@ export async function purgeSentOperations(): Promise<void> {
|
|||||||
await db.runAsync(`DELETE FROM outbox WHERE status = 'sent'`);
|
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<ProblemOp[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.getAllAsync<ProblemOp>(
|
||||||
|
`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<void> {
|
||||||
|
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<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.runAsync('DELETE FROM outbox WHERE uuid = ?', uuid);
|
||||||
|
}
|
||||||
|
|
||||||
export interface OutboxCounts {
|
export interface OutboxCounts {
|
||||||
pending: number;
|
pending: number;
|
||||||
conflict: number;
|
conflict: number;
|
||||||
@@ -173,12 +227,27 @@ export interface MediaOutboxRow {
|
|||||||
|
|
||||||
export async function getPendingMedia(limit = 50): Promise<MediaOutboxRow[]> {
|
export async function getPendingMedia(limit = 50): Promise<MediaOutboxRow[]> {
|
||||||
const db = await getDb();
|
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<MediaOutboxRow>(
|
return db.getAllAsync<MediaOutboxRow>(
|
||||||
`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,
|
limit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Media local (pendiente o con error) de un padre, para previsualizar en la UI. */
|
||||||
|
export async function getMediaOutboxFor(
|
||||||
|
parentEntity: string,
|
||||||
|
parentId: number,
|
||||||
|
): Promise<MediaOutboxRow[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.getAllAsync<MediaOutboxRow>(
|
||||||
|
`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<void> {
|
export async function markMediaSent(uuid: string, mediaId: number | null): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
await db.runAsync(
|
await db.runAsync(
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import {
|
|||||||
Bundle,
|
Bundle,
|
||||||
DeletedTombstones,
|
DeletedTombstones,
|
||||||
Feature,
|
Feature,
|
||||||
|
Inspection,
|
||||||
Issue,
|
Issue,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
IssueTask,
|
IssueTask,
|
||||||
Layer,
|
Layer,
|
||||||
|
Media,
|
||||||
Phase,
|
Phase,
|
||||||
Project,
|
Project,
|
||||||
Template,
|
Template,
|
||||||
@@ -298,6 +300,117 @@ export async function getIssues(projectId: number): Promise<Issue[]> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getIssue(id: number): Promise<Issue | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.getFirstAsync<Issue>('SELECT * FROM issues WHERE id = ?', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLayers(projectId: number): Promise<Layer[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.getAllAsync<Layer>(
|
||||||
|
'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<Feature[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.getAllAsync<Feature & { geometry: string | null }>(
|
||||||
|
'SELECT * FROM features WHERE project_id = ? ORDER BY layer_id, name',
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
return rows.map(parseGeometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFeature(id: number): Promise<Feature | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
const row = await db.getFirstAsync<Feature & { geometry: string | null }>(
|
||||||
|
'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<Inspection[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.getAllAsync<Inspection & { data: string | null }>(
|
||||||
|
'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<string, unknown>) : undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type IssueTaskRow = Omit<IssueTask, 'is_done'> & { is_done: number };
|
||||||
|
|
||||||
|
export async function getIssueTasks(issueId: number): Promise<IssueTask[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.getAllAsync<IssueTaskRow>(
|
||||||
|
'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<IssueComment[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.getAllAsync<IssueComment>(
|
||||||
|
'SELECT * FROM issue_comments WHERE issue_id = ? ORDER BY created_at, id',
|
||||||
|
issueId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplates(projectId: number): 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,
|
||||||
|
);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
fields: r.fields ? (JSON.parse(r.fields) as unknown[]) : [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplate(id: number): Promise<Template | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
const row = await db.getFirstAsync<Template & { fields: string | null }>(
|
||||||
|
'SELECT * FROM templates WHERE id = ?',
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return row
|
||||||
|
? { ...row, fields: row.fields ? (JSON.parse(row.fields) as unknown[]) : [] }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Media YA sincronizada (con url) para un padre. */
|
||||||
|
export async function getMediaFor(
|
||||||
|
parentEntity: string,
|
||||||
|
parentId: number,
|
||||||
|
): Promise<Media[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.getAllAsync<Media>(
|
||||||
|
'SELECT * FROM media WHERE parent_entity = ? AND parent_id = ? ORDER BY id DESC',
|
||||||
|
parentEntity,
|
||||||
|
parentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function countRows(table: string): Promise<number> {
|
export async function countRows(table: string): Promise<number> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const row = await db.getFirstAsync<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`);
|
const row = await db.getFirstAsync<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`);
|
||||||
@@ -313,3 +426,75 @@ export async function applyServerValue(
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
await upsertById(db, table, { id, ...serverValue });
|
await upsertById(db, table, { id, ...serverValue });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- creaciones offline (optimista + reconciliación) ----------
|
||||||
|
|
||||||
|
/** Entidad de creación → tabla local. */
|
||||||
|
const CREATE_TABLE: Record<string, string> = {
|
||||||
|
issue: 'issues',
|
||||||
|
inspection: 'inspections',
|
||||||
|
issue_task: 'issue_tasks',
|
||||||
|
issue_comment: 'issue_comments',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Inserta una fila creada offline con un id temporal negativo y su local_uuid. */
|
||||||
|
export async function insertLocalCreate(
|
||||||
|
entity: string,
|
||||||
|
tempId: number,
|
||||||
|
localUuid: string,
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const table = CREATE_TABLE[entity];
|
||||||
|
if (!table) return;
|
||||||
|
const db = await getDb();
|
||||||
|
const full: Record<string, unknown> = { id: tempId, local_uuid: localUuid, ...row };
|
||||||
|
const cols = Object.keys(full);
|
||||||
|
const sql = `INSERT INTO ${table} (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${cols
|
||||||
|
.map(() => '?')
|
||||||
|
.join(', ')})`;
|
||||||
|
await db.runAsync(sql, ...cols.map((c) => full[c] as never));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sustituye el id temporal de una fila creada offline por el id real del
|
||||||
|
* servidor y re-apunta las FKs de sus hijos (tareas/comentarios/fotos en cola).
|
||||||
|
* Devuelve el id temporal que tenía (para que el motor remapee el outbox).
|
||||||
|
*/
|
||||||
|
export async function reconcileCreate(
|
||||||
|
entity: string,
|
||||||
|
localUuid: string,
|
||||||
|
serverId: number,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const table = CREATE_TABLE[entity];
|
||||||
|
if (!table) return null;
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
const found = await db.getFirstAsync<{ id: number }>(
|
||||||
|
`SELECT id FROM ${table} WHERE local_uuid = ?`,
|
||||||
|
localUuid,
|
||||||
|
);
|
||||||
|
if (!found) return null;
|
||||||
|
const tempId = found.id;
|
||||||
|
|
||||||
|
await db.withTransactionAsync(async () => {
|
||||||
|
if (tempId !== serverId) {
|
||||||
|
await db.runAsync(`UPDATE ${table} SET id = ?, local_uuid = NULL WHERE id = ?`, serverId, tempId);
|
||||||
|
// Hijos de una incidencia creada offline.
|
||||||
|
if (entity === 'issue') {
|
||||||
|
await db.runAsync('UPDATE issue_tasks SET issue_id = ? WHERE issue_id = ?', serverId, tempId);
|
||||||
|
await db.runAsync('UPDATE issue_comments SET issue_id = ? WHERE issue_id = ?', serverId, tempId);
|
||||||
|
}
|
||||||
|
// Fotos en cola que apuntaban a esta fila por su id temporal.
|
||||||
|
await db.runAsync(
|
||||||
|
'UPDATE media_outbox SET parent_id = ? WHERE parent_entity = ? AND parent_id = ?',
|
||||||
|
serverId,
|
||||||
|
entity,
|
||||||
|
tempId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await db.runAsync(`UPDATE ${table} SET local_uuid = NULL WHERE id = ?`, serverId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return tempId;
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
* Versionado simple por `user_version` de SQLite (ver database.ts).
|
* Versionado simple por `user_version` de SQLite (ver database.ts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 1;
|
export const SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
export const SCHEMA_SQL = `
|
export const SCHEMA_SQL = `
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { useSession } from '../auth/session';
|
|||||||
import { LoginScreen } from '../screens/LoginScreen';
|
import { LoginScreen } from '../screens/LoginScreen';
|
||||||
import { ProjectDetailScreen } from '../screens/ProjectDetailScreen';
|
import { ProjectDetailScreen } from '../screens/ProjectDetailScreen';
|
||||||
import { ProjectsScreen } from '../screens/ProjectsScreen';
|
import { ProjectsScreen } from '../screens/ProjectsScreen';
|
||||||
|
import { IssueDetailScreen } from '../screens/IssueDetailScreen';
|
||||||
|
import { FeatureDetailScreen } from '../screens/FeatureDetailScreen';
|
||||||
|
import { InspectionFormScreen } from '../screens/InspectionFormScreen';
|
||||||
|
import { IssueCreateScreen } from '../screens/IssueCreateScreen';
|
||||||
|
import { OutboxScreen } from '../screens/OutboxScreen';
|
||||||
import { RootStackParamList } from './types';
|
import { RootStackParamList } from './types';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
@@ -38,6 +43,31 @@ export function RootNavigator() {
|
|||||||
component={ProjectDetailScreen}
|
component={ProjectDetailScreen}
|
||||||
options={({ route }) => ({ title: route.params.name })}
|
options={({ route }) => ({ title: route.params.name })}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="IssueDetail"
|
||||||
|
component={IssueDetailScreen}
|
||||||
|
options={({ route }) => ({ title: route.params.title })}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="FeatureDetail"
|
||||||
|
component={FeatureDetailScreen}
|
||||||
|
options={({ route }) => ({ title: route.params.name })}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="InspectionForm"
|
||||||
|
component={InspectionFormScreen}
|
||||||
|
options={{ title: 'Nueva inspección', presentation: 'modal' }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="IssueCreate"
|
||||||
|
component={IssueCreateScreen}
|
||||||
|
options={{ title: 'Nueva incidencia', presentation: 'modal' }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Outbox"
|
||||||
|
component={OutboxScreen}
|
||||||
|
options={{ title: 'Cola de sincronización' }}
|
||||||
|
/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
Projects: undefined;
|
Projects: undefined;
|
||||||
ProjectDetail: { projectId: number; name: string };
|
ProjectDetail: { projectId: number; name: string };
|
||||||
|
IssueDetail: { issueId: number; title: string };
|
||||||
|
FeatureDetail: { featureId: number; name: string };
|
||||||
|
InspectionForm: { featureId: number; featureName: string; templateId?: number };
|
||||||
|
IssueCreate: { projectId: number; featureId?: number };
|
||||||
|
Outbox: undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import React from 'react';
|
||||||
|
import { RootStackParamList } from '../navigation/types';
|
||||||
|
import { FeatureDetailContent } from './detail/FeatureDetailContent';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'FeatureDetail'>;
|
||||||
|
|
||||||
|
export function FeatureDetailScreen({ route }: Props) {
|
||||||
|
return <FeatureDetailContent featureId={route.params.featureId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
|
||||||
|
import { Template } from '../api/types';
|
||||||
|
import { getTemplate } from '../db/repositories';
|
||||||
|
import { createInspection } from '../sync/mutations';
|
||||||
|
import { RootStackParamList } from '../navigation/types';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
ChipSelect,
|
||||||
|
COLORS,
|
||||||
|
Field,
|
||||||
|
PrimaryButton,
|
||||||
|
SectionTitle,
|
||||||
|
} from '../ui/components';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'InspectionForm'>;
|
||||||
|
|
||||||
|
interface NormField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'textarea' | 'number' | 'boolean' | 'select';
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
let type = String(f.type ?? 'text').toLowerCase();
|
||||||
|
if (!['text', 'textarea', 'number', 'boolean', 'select'].includes(type)) {
|
||||||
|
if (type === 'checkbox' || type === 'bool') type = 'boolean';
|
||||||
|
else if (type === 'dropdown') type = 'select';
|
||||||
|
else type = 'text';
|
||||||
|
}
|
||||||
|
const options = Array.isArray(f.options)
|
||||||
|
? (f.options as unknown[]).map((o) =>
|
||||||
|
typeof o === 'string' ? o : String((o as Record<string, unknown>)?.value ?? o),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
return { key, label, type: type as NormField['type'], options };
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESULTS = ['pass', 'fail', 'na'] as const;
|
||||||
|
|
||||||
|
export function InspectionFormScreen({ route, navigation }: Props) {
|
||||||
|
const { featureId, featureName, templateId } = route.params;
|
||||||
|
const [template, setTemplate] = useState<Template | null>(null);
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
|
const [result, setResult] = useState<(typeof RESULTS)[number] | undefined>();
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (templateId != null) void getTemplate(templateId).then(setTemplate);
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
const fields: NormField[] = (template?.fields ?? []).map(normalizeField);
|
||||||
|
|
||||||
|
const setValue = useCallback((key: string, v: unknown) => {
|
||||||
|
setValues((prev) => ({ ...prev, [key]: v }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await createInspection({
|
||||||
|
feature_id: featureId,
|
||||||
|
template_id: templateId,
|
||||||
|
data: values,
|
||||||
|
result,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
navigation.goBack();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [featureId, templateId, values, result, notes, navigation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.body}>
|
||||||
|
<Text style={styles.subtitle}>{featureName}</Text>
|
||||||
|
<Text style={styles.tplName}>{template?.name ?? 'Inspección libre'}</Text>
|
||||||
|
|
||||||
|
{fields.map((f) => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (f.type === 'select' && f.options.length) {
|
||||||
|
return (
|
||||||
|
<ChipSelect
|
||||||
|
key={f.key}
|
||||||
|
label={f.label}
|
||||||
|
value={values[f.key] as string | undefined}
|
||||||
|
options={f.options}
|
||||||
|
onChange={(v) => setValue(f.key, v)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={f.key}
|
||||||
|
label={f.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'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<SectionTitle>Resultado</SectionTitle>
|
||||||
|
<Card style={{ marginBottom: 12 }}>
|
||||||
|
<ChipSelect label="" value={result} options={RESULTS} onChange={setResult} />
|
||||||
|
<Field
|
||||||
|
label="Notas"
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="Observaciones…"
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<PrimaryButton title="Guardar inspección" onPress={() => void onSubmit()} loading={saving} />
|
||||||
|
<View style={{ height: 8 }} />
|
||||||
|
<PrimaryButton title="Cancelar" variant="ghost" onPress={() => navigation.goBack()} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
body: { padding: 16 },
|
||||||
|
subtitle: { color: COLORS.muted, fontSize: 13 },
|
||||||
|
tplName: { fontSize: 18, fontWeight: '700', marginBottom: 12 },
|
||||||
|
switchRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
switchLabel: { fontSize: 15, flex: 1 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Alta de incidencia (modal). Crea en local + encola issue.create.
|
||||||
|
*/
|
||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet } from 'react-native';
|
||||||
|
import { IssuePriority, IssueType } from '../api/types';
|
||||||
|
import { createIssue } from '../sync/mutations';
|
||||||
|
import { RootStackParamList } from '../navigation/types';
|
||||||
|
import { ChipSelect, Field, PrimaryButton } from '../ui/components';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'IssueCreate'>;
|
||||||
|
|
||||||
|
const PRIORITIES: readonly IssuePriority[] = ['low', 'medium', 'high', 'critical'];
|
||||||
|
const TYPES: readonly IssueType[] = ['defect', 'safety', 'quality', 'documentation', 'other'];
|
||||||
|
|
||||||
|
export function IssueCreateScreen({ route, navigation }: Props) {
|
||||||
|
const { projectId, featureId } = route.params;
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [priority, setPriority] = useState<IssuePriority>('medium');
|
||||||
|
const [type, setType] = useState<IssueType>('defect');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await createIssue({
|
||||||
|
project_id: projectId,
|
||||||
|
feature_id: featureId,
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
priority,
|
||||||
|
type,
|
||||||
|
status: 'open',
|
||||||
|
});
|
||||||
|
navigation.goBack();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [title, description, priority, type, projectId, featureId, navigation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.body}>
|
||||||
|
<Field label="Título" value={title} onChangeText={setTitle} placeholder="Resumen de la incidencia" />
|
||||||
|
<Field
|
||||||
|
label="Descripción"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="Detalle…"
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
<ChipSelect label="Prioridad" value={priority} options={PRIORITIES} onChange={setPriority} />
|
||||||
|
<ChipSelect label="Tipo" value={type} options={TYPES} onChange={setType} />
|
||||||
|
<PrimaryButton
|
||||||
|
title="Crear incidencia"
|
||||||
|
onPress={() => void onSubmit()}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!title.trim()}
|
||||||
|
/>
|
||||||
|
<PrimaryButton title="Cancelar" variant="ghost" onPress={() => navigation.goBack()} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
body: { padding: 16, gap: 4 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import React from 'react';
|
||||||
|
import { RootStackParamList } from '../navigation/types';
|
||||||
|
import { IssueDetailContent } from './detail/IssueDetailContent';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'IssueDetail'>;
|
||||||
|
|
||||||
|
export function IssueDetailScreen({ route }: Props) {
|
||||||
|
return <IssueDetailContent issueId={route.params.issueId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Revisión del outbox: operaciones en conflicto o con error. Permite reintentar
|
||||||
|
* (volver a encolar) o descartar. Los conflictos muestran el valor del servidor.
|
||||||
|
*/
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { discardOp, getProblemOps, ProblemOp, retryOp } from '../db/outbox';
|
||||||
|
import { Badge, Card, COLORS, EmptyState, PrimaryButton } from '../ui/components';
|
||||||
|
|
||||||
|
export function OutboxScreen() {
|
||||||
|
const [ops, setOps] = useState<ProblemOp[]>([]);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
void getProblemOps().then(setOps);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(load);
|
||||||
|
|
||||||
|
const onRetry = useCallback(
|
||||||
|
async (uuid: string) => {
|
||||||
|
await retryOp(uuid);
|
||||||
|
load();
|
||||||
|
},
|
||||||
|
[load],
|
||||||
|
);
|
||||||
|
const onDiscard = useCallback(
|
||||||
|
async (uuid: string) => {
|
||||||
|
await discardOp(uuid);
|
||||||
|
load();
|
||||||
|
},
|
||||||
|
[load],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.body}>
|
||||||
|
{ops.length === 0 && <EmptyState text="Nada pendiente de revisión." />}
|
||||||
|
{ops.map((o) => (
|
||||||
|
<Card key={o.uuid} style={styles.card}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Text style={styles.entity}>
|
||||||
|
{o.entity}.{o.op}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
label={o.status}
|
||||||
|
color={o.status === 'conflict' ? COLORS.warn : COLORS.danger}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{o.error ? <Text style={styles.error}>{o.error}</Text> : null}
|
||||||
|
{o.server_payload ? (
|
||||||
|
<Text style={styles.mono} numberOfLines={6}>
|
||||||
|
Servidor: {o.server_payload}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text style={styles.mono} numberOfLines={6}>
|
||||||
|
Local: {o.data}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<PrimaryButton title="Reintentar" variant="ghost" onPress={() => void onRetry(o.uuid)} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<PrimaryButton title="Descartar" variant="danger" onPress={() => void onDiscard(o.uuid)} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
body: { padding: 16, gap: 12 },
|
||||||
|
card: { gap: 8 },
|
||||||
|
headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
|
entity: { fontSize: 15, fontWeight: '700' },
|
||||||
|
error: { color: COLORS.danger, fontSize: 13 },
|
||||||
|
mono: { fontSize: 12, color: COLORS.muted, fontFamily: 'monospace' },
|
||||||
|
actions: { flexDirection: 'row', gap: 8, marginTop: 4 },
|
||||||
|
});
|
||||||
@@ -1,52 +1,63 @@
|
|||||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { Issue, Phase } from '../api/types';
|
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { SyncStatusBar } from '../components/SyncStatusBar';
|
import { SyncStatusBar } from '../components/SyncStatusBar';
|
||||||
import { getOutboxCounts, OutboxCounts } from '../db/outbox';
|
import { getOutboxCounts, OutboxCounts } from '../db/outbox';
|
||||||
import { getCursor, getIssues, getPhases } from '../db/repositories';
|
|
||||||
import { isOnline } from '../net/connectivity';
|
import { isOnline } from '../net/connectivity';
|
||||||
import { runSync } from '../sync/engine';
|
import { runSync } from '../sync/engine';
|
||||||
|
import { useAutoSync } from '../sync/useAutoSync';
|
||||||
import { RootStackParamList } from '../navigation/types';
|
import { RootStackParamList } from '../navigation/types';
|
||||||
|
import { COLORS, PrimaryButton } from '../ui/components';
|
||||||
|
import { PhasesSection } from './sections/PhasesSection';
|
||||||
|
import { FeaturesSection } from './sections/FeaturesSection';
|
||||||
|
import { IssuesSection } from './sections/IssuesSection';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'ProjectDetail'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'ProjectDetail'>;
|
||||||
|
|
||||||
const EMPTY: OutboxCounts = { pending: 0, conflict: 0, error: 0, mediaPending: 0 };
|
const EMPTY: OutboxCounts = { pending: 0, conflict: 0, error: 0, mediaPending: 0 };
|
||||||
|
const TABS = ['Fases', 'Features', 'Incidencias'] as const;
|
||||||
|
type Tab = (typeof TABS)[number];
|
||||||
|
|
||||||
export function ProjectDetailScreen({ route }: Props) {
|
export function ProjectDetailScreen({ route, navigation }: Props) {
|
||||||
const { projectId } = route.params;
|
const { projectId } = route.params;
|
||||||
const [phases, setPhases] = useState<Phase[]>([]);
|
const [tab, setTab] = useState<Tab>('Fases');
|
||||||
const [issues, setIssues] = useState<Issue[]>([]);
|
|
||||||
const [counts, setCounts] = useState<OutboxCounts>(EMPTY);
|
const [counts, setCounts] = useState<OutboxCounts>(EMPTY);
|
||||||
const [cursor, setCursorState] = useState<string | null>(null);
|
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
// Se incrementa tras cada sync para forzar el recargado de la sección visible.
|
||||||
|
const [nonce, setNonce] = useState(0);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refreshCounts = useCallback(() => {
|
||||||
const [ph, iss, c, cur] = await Promise.all([
|
void getOutboxCounts().then(setCounts);
|
||||||
getPhases(projectId),
|
}, []);
|
||||||
getIssues(projectId),
|
|
||||||
getOutboxCounts(),
|
|
||||||
getCursor(projectId),
|
|
||||||
]);
|
|
||||||
setPhases(ph);
|
|
||||||
setIssues(iss);
|
|
||||||
setCounts(c);
|
|
||||||
setCursorState(cur);
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useFocusEffect(refreshCounts);
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
|
/** Ciclo de sync; refresca contadores y la sección visible. */
|
||||||
|
const doSync = useCallback(async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
return await runSync(projectId);
|
||||||
|
} finally {
|
||||||
|
refreshCounts();
|
||||||
|
setNonce((n) => n + 1);
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}, [projectId, refreshCounts]);
|
||||||
|
|
||||||
|
// Auto-sync (silencioso) al recuperar red / volver a primer plano / por intervalo.
|
||||||
|
useAutoSync(true, async () => {
|
||||||
|
await doSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync manual (con resumen).
|
||||||
const onSync = useCallback(async () => {
|
const onSync = useCallback(async () => {
|
||||||
if (!(await isOnline())) {
|
if (!(await isOnline())) {
|
||||||
Alert.alert('Sin conexión', 'Conéctate para sincronizar.');
|
Alert.alert('Sin conexión', 'Conéctate para sincronizar.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSyncing(true);
|
|
||||||
try {
|
try {
|
||||||
const report = await runSync(projectId);
|
const report = await doSync();
|
||||||
await refresh();
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Sincronización completada',
|
'Sincronización completada',
|
||||||
`Enviadas: ${report.applied}/${report.pushed}\n` +
|
`Enviadas: ${report.applied}/${report.pushed}\n` +
|
||||||
@@ -55,71 +66,53 @@ export function ProjectDetailScreen({ route }: Props) {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alert.alert('Error de sincronización', e instanceof Error ? e.message : String(e));
|
Alert.alert('Error de sincronización', e instanceof Error ? e.message : String(e));
|
||||||
} finally {
|
|
||||||
setSyncing(false);
|
|
||||||
}
|
}
|
||||||
}, [projectId, refresh]);
|
}, [doSync]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<SyncStatusBar counts={counts} syncing={syncing} />
|
<SyncStatusBar
|
||||||
|
counts={counts}
|
||||||
|
syncing={syncing}
|
||||||
|
onPress={() => navigation.navigate('Outbox')}
|
||||||
|
/>
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={styles.body}>
|
<View style={styles.tabs}>
|
||||||
<Text style={styles.section}>Fases ({phases.length})</Text>
|
{TABS.map((t) => (
|
||||||
{phases.map((p) => (
|
<TouchableOpacity
|
||||||
<View key={p.id} style={styles.card}>
|
key={t}
|
||||||
<Text style={styles.cardTitle}>{p.name}</Text>
|
style={[styles.tab, tab === t && styles.tabActive]}
|
||||||
<Text style={styles.cardMeta}>{Math.round(p.progress_percent ?? 0)}%</Text>
|
onPress={() => setTab(t)}
|
||||||
</View>
|
>
|
||||||
|
<Text style={[styles.tabText, tab === t && styles.tabTextActive]}>{t}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
<Text style={styles.section}>Incidencias ({issues.length})</Text>
|
<View style={styles.content} key={`${tab}-${nonce}`}>
|
||||||
{issues.map((i) => (
|
{tab === 'Fases' && <PhasesSection projectId={projectId} />}
|
||||||
<View key={i.id} style={styles.card}>
|
{tab === 'Features' && <FeaturesSection projectId={projectId} />}
|
||||||
<Text style={styles.cardTitle}>{i.title}</Text>
|
{tab === 'Incidencias' && <IssuesSection projectId={projectId} />}
|
||||||
<Text style={styles.cardMeta}>
|
</View>
|
||||||
{i.priority ?? '—'} · {i.status ?? '—'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Text style={styles.cursor}>
|
<View style={styles.footer}>
|
||||||
Último sync: {cursor ? new Date(cursor).toLocaleString() : 'nunca'}
|
<PrimaryButton
|
||||||
</Text>
|
title={syncing ? 'Sincronizando…' : 'Sincronizar'}
|
||||||
</ScrollView>
|
onPress={() => void onSync()}
|
||||||
|
loading={syncing}
|
||||||
<TouchableOpacity
|
/>
|
||||||
style={[styles.syncBtn, syncing && styles.syncBtnDisabled]}
|
</View>
|
||||||
onPress={onSync}
|
|
||||||
disabled={syncing}
|
|
||||||
>
|
|
||||||
<Text style={styles.syncBtnText}>{syncing ? 'Sincronizando…' : 'Sincronizar'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1 },
|
container: { flex: 1 },
|
||||||
body: { padding: 16, gap: 8 },
|
tabs: { flexDirection: 'row', borderBottomWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
|
||||||
section: { fontSize: 16, fontWeight: '700', marginTop: 12 },
|
tab: { flex: 1, paddingVertical: 12, alignItems: 'center' },
|
||||||
card: {
|
tabActive: { borderBottomWidth: 2, borderColor: COLORS.primary },
|
||||||
flexDirection: 'row',
|
tabText: { fontSize: 14, color: COLORS.muted },
|
||||||
justifyContent: 'space-between',
|
tabTextActive: { color: COLORS.primary, fontWeight: '700' },
|
||||||
backgroundColor: '#f4f4f4',
|
content: { flex: 1 },
|
||||||
borderRadius: 8,
|
footer: { padding: 12, borderTopWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
|
||||||
padding: 12,
|
|
||||||
},
|
|
||||||
cardTitle: { fontSize: 15, flex: 1 },
|
|
||||||
cardMeta: { fontSize: 13, color: '#666', marginLeft: 8 },
|
|
||||||
cursor: { marginTop: 20, color: '#888', fontSize: 12, textAlign: 'center' },
|
|
||||||
syncBtn: {
|
|
||||||
backgroundColor: '#1f6f43',
|
|
||||||
margin: 16,
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 14,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
syncBtnDisabled: { opacity: 0.5 },
|
|
||||||
syncBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import * as api from '../api/endpoints';
|
import * as api from '../api/endpoints';
|
||||||
import { Project } from '../api/types';
|
import { Project } from '../api/types';
|
||||||
import { useSession } from '../auth/session';
|
import { useSession } from '../auth/session';
|
||||||
import { getCursor, getProjects, saveProjectList } from '../db/repositories';
|
import { getCursor, getProjects, saveProjectList, setActiveProjectId } from '../db/repositories';
|
||||||
import { isOnline } from '../net/connectivity';
|
import { isOnline } from '../net/connectivity';
|
||||||
import { initialPull, runSync } from '../sync/engine';
|
import { initialPull, runSync } from '../sync/engine';
|
||||||
import { RootStackParamList } from '../navigation/types';
|
import { RootStackParamList } from '../navigation/types';
|
||||||
@@ -52,6 +52,7 @@ export function ProjectsScreen({ navigation }: Props) {
|
|||||||
async (p: Project) => {
|
async (p: Project) => {
|
||||||
setOpening(p.id);
|
setOpening(p.id);
|
||||||
try {
|
try {
|
||||||
|
await setActiveProjectId(p.id);
|
||||||
const online = await isOnline();
|
const online = await isOnline();
|
||||||
if (online) {
|
if (online) {
|
||||||
const cursor = await getCursor(p.id);
|
const cursor = await getCursor(p.id);
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Contenido del detalle de una feature: estado/progreso editable, inspecciones
|
||||||
|
* y fotos. Reutilizado en móvil (pantalla) y tablet (panel del maestro-detalle).
|
||||||
|
*/
|
||||||
|
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 { hasPermission, useSession } from '../../auth/session';
|
||||||
|
import { getFeature, getInspectionsByFeature } from '../../db/repositories';
|
||||||
|
import { updateFeature } from '../../sync/mutations';
|
||||||
|
import { RootStackParamList } from '../../navigation/types';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
ChipSelect,
|
||||||
|
COLORS,
|
||||||
|
EmptyState,
|
||||||
|
PrimaryButton,
|
||||||
|
SectionTitle,
|
||||||
|
} from '../../ui/components';
|
||||||
|
import { MediaStrip } from '../../ui/MediaStrip';
|
||||||
|
|
||||||
|
const STATUSES = ['pending', 'in_progress', 'completed', 'blocked'] as const;
|
||||||
|
const QUICK_PROGRESS = [0, 25, 50, 75, 100];
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
export function FeatureDetailContent({ featureId }: { featureId: number }) {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { user } = useSession();
|
||||||
|
const canProgress = hasPermission(user, 'update progress');
|
||||||
|
const canInspect = hasPermission(user, 'create inspections');
|
||||||
|
|
||||||
|
const [feature, setFeature] = useState<Feature | null>(null);
|
||||||
|
const [inspections, setInspections] = useState<Inspection[]>([]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const [f, ins] = await Promise.all([
|
||||||
|
getFeature(featureId),
|
||||||
|
getInspectionsByFeature(featureId),
|
||||||
|
]);
|
||||||
|
setFeature(f);
|
||||||
|
setInspections(ins);
|
||||||
|
}, [featureId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const onStatus = useCallback(
|
||||||
|
async (status: string) => {
|
||||||
|
await updateFeature({ id: featureId, status });
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[featureId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
async (progress: number) => {
|
||||||
|
await updateFeature({ id: featureId, progress });
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[featureId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!feature) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Text style={{ color: COLORS.muted }}>Cargando…</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.body}>
|
||||||
|
<Text style={styles.title}>{feature.name}</Text>
|
||||||
|
<View style={styles.badges}>
|
||||||
|
{feature.status && <Badge label={feature.status} color={COLORS.primary} />}
|
||||||
|
<Badge label={`${Math.round(feature.progress ?? 0)}%`} color={COLORS.muted} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<MediaStrip
|
||||||
|
parentEntity="feature"
|
||||||
|
parentId={feature.id}
|
||||||
|
canUpload={hasPermission(user, 'upload media')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{canProgress && (
|
||||||
|
<Card style={styles.editCard}>
|
||||||
|
<ChipSelect
|
||||||
|
label="Estado"
|
||||||
|
value={feature.status as (typeof STATUSES)[number] | undefined}
|
||||||
|
options={STATUSES}
|
||||||
|
onChange={(v) => void onStatus(v)}
|
||||||
|
/>
|
||||||
|
<Text style={styles.fieldLabel}>Progreso</Text>
|
||||||
|
<View style={styles.progressRow}>
|
||||||
|
{QUICK_PROGRESS.map((p) => {
|
||||||
|
const active = Math.round(feature.progress ?? 0) === p;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={p}
|
||||||
|
style={[styles.pBtn, active && styles.pBtnActive]}
|
||||||
|
onPress={() => void onProgress(p)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.pText, active && styles.pTextActive]}>{p}%</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionTitle>Inspecciones ({inspections.length})</SectionTitle>
|
||||||
|
{inspections.length === 0 && <EmptyState text="Sin inspecciones." />}
|
||||||
|
{inspections.map((ins) => (
|
||||||
|
<Card key={ins.id} style={styles.insCard}>
|
||||||
|
<Text style={styles.insTitle}>
|
||||||
|
{ins.result ?? ins.status ?? 'Inspección'} ·{' '}
|
||||||
|
{ins.created_at ? new Date(ins.created_at).toLocaleDateString() : ''}
|
||||||
|
</Text>
|
||||||
|
{ins.notes ? <Text style={styles.insNotes}>{ins.notes}</Text> : null}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{canInspect && (
|
||||||
|
<PrimaryButton
|
||||||
|
title="Nueva inspección"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() =>
|
||||||
|
navigation.navigate('InspectionForm', {
|
||||||
|
featureId: feature.id,
|
||||||
|
featureName: feature.name,
|
||||||
|
templateId: feature.template_id ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
body: { padding: 16, gap: 6 },
|
||||||
|
title: { fontSize: 20, fontWeight: '700' },
|
||||||
|
badges: { flexDirection: 'row', gap: 6, marginTop: 6 },
|
||||||
|
editCard: { marginTop: 12 },
|
||||||
|
fieldLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 6, fontWeight: '600' },
|
||||||
|
progressRow: { flexDirection: 'row', gap: 6, flexWrap: 'wrap' },
|
||||||
|
pBtn: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.border,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
pBtnActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
|
||||||
|
pText: { color: COLORS.muted, fontWeight: '600' },
|
||||||
|
pTextActive: { color: '#fff' },
|
||||||
|
insCard: { marginBottom: 6 },
|
||||||
|
insTitle: { fontSize: 14, fontWeight: '600' },
|
||||||
|
insNotes: { fontSize: 13, color: COLORS.muted, marginTop: 2 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Contenido del detalle de una incidencia. Se reutiliza en móvil (pantalla
|
||||||
|
* navegada) y en tablet (panel derecho del maestro-detalle).
|
||||||
|
*
|
||||||
|
* Todas las acciones pasan por src/sync/mutations.ts: escriben en local y
|
||||||
|
* encolan la operación. Tras cada cambio refrescamos desde la BD.
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import {
|
||||||
|
Issue,
|
||||||
|
IssueComment,
|
||||||
|
IssuePriority,
|
||||||
|
IssueStatus,
|
||||||
|
IssueTask,
|
||||||
|
} from '../../api/types';
|
||||||
|
import { hasPermission, useSession } from '../../auth/session';
|
||||||
|
import {
|
||||||
|
getIssue,
|
||||||
|
getIssueComments,
|
||||||
|
getIssueTasks,
|
||||||
|
} from '../../db/repositories';
|
||||||
|
import {
|
||||||
|
createIssueComment,
|
||||||
|
createIssueTask,
|
||||||
|
updateIssue,
|
||||||
|
updateIssueTask,
|
||||||
|
} from '../../sync/mutations';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
ChipSelect,
|
||||||
|
COLORS,
|
||||||
|
EmptyState,
|
||||||
|
Field,
|
||||||
|
ISSUE_PRIORITY_COLOR,
|
||||||
|
ISSUE_STATUS_COLOR,
|
||||||
|
PrimaryButton,
|
||||||
|
SectionTitle,
|
||||||
|
} from '../../ui/components';
|
||||||
|
import { MediaStrip } from '../../ui/MediaStrip';
|
||||||
|
|
||||||
|
const STATUSES: readonly IssueStatus[] = ['open', 'in_review', 'resolved', 'closed'];
|
||||||
|
const PRIORITIES: readonly IssuePriority[] = ['low', 'medium', 'high', 'critical'];
|
||||||
|
|
||||||
|
export function IssueDetailContent({ issueId }: { issueId: number }) {
|
||||||
|
const { user } = useSession();
|
||||||
|
const canEdit = hasPermission(user, 'edit issues');
|
||||||
|
|
||||||
|
const [issue, setIssue] = useState<Issue | null>(null);
|
||||||
|
const [tasks, setTasks] = useState<IssueTask[]>([]);
|
||||||
|
const [comments, setComments] = useState<IssueComment[]>([]);
|
||||||
|
const [newTask, setNewTask] = useState('');
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const [i, t, c] = await Promise.all([
|
||||||
|
getIssue(issueId),
|
||||||
|
getIssueTasks(issueId),
|
||||||
|
getIssueComments(issueId),
|
||||||
|
]);
|
||||||
|
setIssue(i);
|
||||||
|
setTasks(t);
|
||||||
|
setComments(c);
|
||||||
|
}, [issueId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const onStatus = useCallback(
|
||||||
|
async (status: IssueStatus) => {
|
||||||
|
await updateIssue({ id: issueId, status });
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[issueId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPriority = useCallback(
|
||||||
|
async (priority: IssuePriority) => {
|
||||||
|
await updateIssue({ id: issueId, priority });
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[issueId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onToggleTask = useCallback(
|
||||||
|
async (task: IssueTask) => {
|
||||||
|
await updateIssueTask({ id: task.id, is_done: !task.is_done });
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAddTask = useCallback(async () => {
|
||||||
|
const title = newTask.trim();
|
||||||
|
if (!title) return;
|
||||||
|
setNewTask('');
|
||||||
|
await createIssueTask({ issue_id: issueId, title });
|
||||||
|
await refresh();
|
||||||
|
}, [newTask, issueId, refresh]);
|
||||||
|
|
||||||
|
const onAddComment = useCallback(async () => {
|
||||||
|
const body = newComment.trim();
|
||||||
|
if (!body) return;
|
||||||
|
setNewComment('');
|
||||||
|
await createIssueComment({ issue_id: issueId, body });
|
||||||
|
await refresh();
|
||||||
|
}, [newComment, issueId, refresh]);
|
||||||
|
|
||||||
|
if (!issue) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Text style={{ color: COLORS.muted }}>Cargando…</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = tasks.filter((t) => t.is_done).length;
|
||||||
|
const progress = tasks.length ? Math.round((done / tasks.length) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.body}>
|
||||||
|
<Text style={styles.title}>{issue.title}</Text>
|
||||||
|
<View style={styles.badges}>
|
||||||
|
{issue.status && (
|
||||||
|
<Badge label={issue.status} color={ISSUE_STATUS_COLOR[issue.status] ?? COLORS.muted} />
|
||||||
|
)}
|
||||||
|
{issue.priority && (
|
||||||
|
<Badge
|
||||||
|
label={issue.priority}
|
||||||
|
color={ISSUE_PRIORITY_COLOR[issue.priority] ?? COLORS.muted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{issue.type && <Badge label={issue.type} color={COLORS.muted} />}
|
||||||
|
</View>
|
||||||
|
{issue.description ? <Text style={styles.desc}>{issue.description}</Text> : null}
|
||||||
|
|
||||||
|
<MediaStrip parentEntity="issue" parentId={issue.id} canUpload={hasPermission(user, 'upload media')} />
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<Card style={styles.editCard}>
|
||||||
|
<ChipSelect
|
||||||
|
label="Estado"
|
||||||
|
value={issue.status}
|
||||||
|
options={STATUSES}
|
||||||
|
onChange={(v) => void onStatus(v)}
|
||||||
|
/>
|
||||||
|
<ChipSelect
|
||||||
|
label="Prioridad"
|
||||||
|
value={issue.priority}
|
||||||
|
options={PRIORITIES}
|
||||||
|
onChange={(v) => void onPriority(v)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionTitle>
|
||||||
|
Tareas · {done}/{tasks.length} ({progress}%)
|
||||||
|
</SectionTitle>
|
||||||
|
{tasks.length === 0 && <EmptyState text="Sin tareas." />}
|
||||||
|
{tasks.map((t) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={t.id}
|
||||||
|
style={styles.taskRow}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onPress={() => void onToggleTask(t)}
|
||||||
|
>
|
||||||
|
<Text style={styles.checkbox}>{t.is_done ? '☑' : '☐'}</Text>
|
||||||
|
<Text style={[styles.taskText, t.is_done && styles.taskDone]}>{t.title}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
{canEdit && (
|
||||||
|
<View style={styles.addRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Field
|
||||||
|
label=""
|
||||||
|
placeholder="Nueva tarea…"
|
||||||
|
value={newTask}
|
||||||
|
onChangeText={setNewTask}
|
||||||
|
onSubmitEditing={() => void onAddTask()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<PrimaryButton title="Añadir" onPress={() => void onAddTask()} disabled={!newTask.trim()} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionTitle>Comentarios</SectionTitle>
|
||||||
|
{comments.length === 0 && <EmptyState text="Sin comentarios." />}
|
||||||
|
{comments.map((c) => (
|
||||||
|
<Card key={c.id} style={styles.comment}>
|
||||||
|
<Text style={styles.commentBody}>{c.body}</Text>
|
||||||
|
{c.created_at ? (
|
||||||
|
<Text style={styles.commentMeta}>{new Date(c.created_at).toLocaleString()}</Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
<View style={styles.addRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Field
|
||||||
|
label=""
|
||||||
|
placeholder="Escribe un comentario…"
|
||||||
|
value={newComment}
|
||||||
|
onChangeText={setNewComment}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<PrimaryButton
|
||||||
|
title="Enviar"
|
||||||
|
onPress={() => void onAddComment()}
|
||||||
|
disabled={!newComment.trim()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
body: { padding: 16, gap: 4 },
|
||||||
|
title: { fontSize: 20, fontWeight: '700' },
|
||||||
|
badges: { flexDirection: 'row', gap: 6, marginTop: 6, flexWrap: 'wrap' },
|
||||||
|
desc: { marginTop: 8, color: '#333', fontSize: 14 },
|
||||||
|
editCard: { marginTop: 12, gap: 4 },
|
||||||
|
taskRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: 10 },
|
||||||
|
checkbox: { fontSize: 20 },
|
||||||
|
taskText: { fontSize: 15, flex: 1 },
|
||||||
|
taskDone: { textDecorationLine: 'line-through', color: COLORS.muted },
|
||||||
|
addRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, marginTop: 4 },
|
||||||
|
comment: { marginBottom: 6 },
|
||||||
|
commentBody: { fontSize: 14 },
|
||||||
|
commentMeta: { fontSize: 11, color: COLORS.muted, marginTop: 4 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Sección Features: maestro-detalle con conmutador Lista/Mapa en el panel
|
||||||
|
* maestro. Lista o mapa a la izquierda, detalle a la derecha (tablet) o
|
||||||
|
* navegación a pantalla (móvil).
|
||||||
|
*/
|
||||||
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||||
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { Feature } from '../../api/types';
|
||||||
|
import { getFeatures } from '../../db/repositories';
|
||||||
|
import { RootStackParamList } from '../../navigation/types';
|
||||||
|
import { Badge, COLORS, EmptyState } from '../../ui/components';
|
||||||
|
import { FeatureMap } from '../../ui/FeatureMap';
|
||||||
|
import { MasterDetail } from '../../ui/MasterDetail';
|
||||||
|
import { FeatureDetailContent } from '../detail/FeatureDetailContent';
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
type ViewMode = 'list' | 'map';
|
||||||
|
|
||||||
|
export function FeaturesSection({ projectId }: { projectId: number }) {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const [features, setFeatures] = useState<Feature[]>([]);
|
||||||
|
const [mode, setMode] = useState<ViewMode>('list');
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
void getFeatures(projectId).then(setFeatures);
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useFocusEffect(load);
|
||||||
|
|
||||||
|
const goPhone = (id: number) => {
|
||||||
|
const f = features.find((x) => x.id === id);
|
||||||
|
navigation.navigate('FeatureDetail', { featureId: id, name: f?.name ?? 'Feature' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MasterDetail
|
||||||
|
onSelectPhone={goPhone}
|
||||||
|
renderDetail={(id) => <FeatureDetailContent featureId={id} />}
|
||||||
|
renderMaster={({ selectedId, onSelect }) => (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View style={styles.toggle}>
|
||||||
|
{(['list', 'map'] as ViewMode[]).map((m) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={m}
|
||||||
|
style={[styles.toggleBtn, mode === m && styles.toggleActive]}
|
||||||
|
onPress={() => setMode(m)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.toggleText, mode === m && styles.toggleTextActive]}>
|
||||||
|
{m === 'list' ? 'Lista' : 'Mapa'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{features.length === 0 ? (
|
||||||
|
<EmptyState text="Sin features." />
|
||||||
|
) : mode === 'map' ? (
|
||||||
|
<FeatureMap features={features} selectedId={selectedId} onSelect={onSelect} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={features}
|
||||||
|
keyExtractor={(f) => String(f.id)}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.row, item.id === selectedId && styles.rowActive]}
|
||||||
|
onPress={() => onSelect(item.id)}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.name}>{item.name}</Text>
|
||||||
|
{item.status ? <Text style={styles.meta}>{item.status}</Text> : null}
|
||||||
|
</View>
|
||||||
|
<Badge label={`${Math.round(item.progress ?? 0)}%`} color={COLORS.muted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
toggle: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
padding: 8,
|
||||||
|
gap: 8,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: COLORS.border,
|
||||||
|
},
|
||||||
|
toggleBtn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.border,
|
||||||
|
},
|
||||||
|
toggleActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
|
||||||
|
toggleText: { fontSize: 13, color: COLORS.muted },
|
||||||
|
toggleTextActive: { color: '#fff', fontWeight: '700' },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: COLORS.border,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
rowActive: { backgroundColor: '#eef5f0' },
|
||||||
|
name: { fontSize: 15, fontWeight: '600' },
|
||||||
|
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Sección Incidencias: maestro-detalle + alta de incidencia.
|
||||||
|
*/
|
||||||
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||||
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { Issue } from '../../api/types';
|
||||||
|
import { hasPermission, useSession } from '../../auth/session';
|
||||||
|
import { getIssues } from '../../db/repositories';
|
||||||
|
import { RootStackParamList } from '../../navigation/types';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
COLORS,
|
||||||
|
EmptyState,
|
||||||
|
ISSUE_PRIORITY_COLOR,
|
||||||
|
ISSUE_STATUS_COLOR,
|
||||||
|
PrimaryButton,
|
||||||
|
} from '../../ui/components';
|
||||||
|
import { MasterDetail } from '../../ui/MasterDetail';
|
||||||
|
import { IssueDetailContent } from '../detail/IssueDetailContent';
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
export function IssuesSection({ projectId }: { projectId: number }) {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { user } = useSession();
|
||||||
|
const canCreate = hasPermission(user, 'create issues');
|
||||||
|
const [issues, setIssues] = useState<Issue[]>([]);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
void getIssues(projectId).then(setIssues);
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useFocusEffect(load);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{canCreate && (
|
||||||
|
<View style={styles.toolbar}>
|
||||||
|
<PrimaryButton
|
||||||
|
title="+ Nueva incidencia"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => navigation.navigate('IssueCreate', { projectId })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<MasterDetail
|
||||||
|
onSelectPhone={(id) => {
|
||||||
|
const i = issues.find((x) => x.id === id);
|
||||||
|
navigation.navigate('IssueDetail', { issueId: id, title: i?.title ?? 'Incidencia' });
|
||||||
|
}}
|
||||||
|
renderDetail={(id) => <IssueDetailContent issueId={id} />}
|
||||||
|
renderMaster={({ selectedId, onSelect }) =>
|
||||||
|
issues.length === 0 ? (
|
||||||
|
<EmptyState text="Sin incidencias." />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={issues}
|
||||||
|
keyExtractor={(i) => String(i.id)}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.row, item.id === selectedId && styles.rowActive]}
|
||||||
|
onPress={() => onSelect(item.id)}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.badges}>
|
||||||
|
{item.priority && (
|
||||||
|
<Badge
|
||||||
|
label={item.priority}
|
||||||
|
color={ISSUE_PRIORITY_COLOR[item.priority] ?? COLORS.muted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.status && (
|
||||||
|
<Badge
|
||||||
|
label={item.status}
|
||||||
|
color={ISSUE_STATUS_COLOR[item.status] ?? COLORS.muted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.id < 0 && <Badge label="local" color={COLORS.warn} />}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
toolbar: { padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
|
||||||
|
row: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: COLORS.border,
|
||||||
|
},
|
||||||
|
rowActive: { backgroundColor: '#eef5f0' },
|
||||||
|
title: { fontSize: 15, fontWeight: '600' },
|
||||||
|
badges: { flexDirection: 'row', gap: 6, marginTop: 6 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Sección Fases: lista con barra de progreso y registro rápido de avance
|
||||||
|
* (operación append-only progress_update).
|
||||||
|
*/
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { Phase } from '../../api/types';
|
||||||
|
import { hasPermission, useSession } from '../../auth/session';
|
||||||
|
import { getPhases } from '../../db/repositories';
|
||||||
|
import { recordProgressUpdate } from '../../sync/mutations';
|
||||||
|
import { Card, COLORS, EmptyState } from '../../ui/components';
|
||||||
|
import { useLayout } from '../../ui/responsive';
|
||||||
|
|
||||||
|
const QUICK = [25, 50, 75, 100];
|
||||||
|
|
||||||
|
export function PhasesSection({ projectId }: { projectId: number }) {
|
||||||
|
const { user } = useSession();
|
||||||
|
const canProgress = hasPermission(user, 'update progress');
|
||||||
|
const { columns, gutter } = useLayout();
|
||||||
|
const [phases, setPhases] = useState<Phase[]>([]);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
void getPhases(projectId).then(setPhases);
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useFocusEffect(load);
|
||||||
|
|
||||||
|
const onQuick = useCallback(
|
||||||
|
async (phase: Phase, progress: number) => {
|
||||||
|
await recordProgressUpdate({ phase_id: phase.id, progress });
|
||||||
|
// Reflejo optimista en la barra (el valor real llega en el próximo PULL).
|
||||||
|
setPhases((prev) =>
|
||||||
|
prev.map((p) => (p.id === phase.id ? { ...p, progress_percent: progress } : p)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (phases.length === 0) return <EmptyState text="Sin fases." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={[styles.body, { padding: gutter }]}>
|
||||||
|
<View style={[styles.grid, columns > 1 && { gap: gutter }]}>
|
||||||
|
{phases.map((p) => {
|
||||||
|
const pct = Math.round(p.progress_percent ?? 0);
|
||||||
|
return (
|
||||||
|
<Card key={p.id} style={[styles.card, columns > 1 && { width: `${100 / columns - 2}%` }]}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
{p.color ? <View style={[styles.dot, { backgroundColor: p.color }]} /> : null}
|
||||||
|
<Text style={styles.name}>{p.name}</Text>
|
||||||
|
<Text style={styles.pct}>{pct}%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.track}>
|
||||||
|
<View style={[styles.fill, { width: `${pct}%` }]} />
|
||||||
|
</View>
|
||||||
|
{canProgress && (
|
||||||
|
<View style={styles.quickRow}>
|
||||||
|
{QUICK.map((q) => (
|
||||||
|
<TouchableOpacity key={q} style={styles.quickBtn} onPress={() => void onQuick(p, q)}>
|
||||||
|
<Text style={styles.quickText}>{q}%</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
body: { gap: 10 },
|
||||||
|
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
||||||
|
card: { width: '100%', gap: 8 },
|
||||||
|
headerRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
|
dot: { width: 12, height: 12, borderRadius: 6 },
|
||||||
|
name: { fontSize: 15, fontWeight: '600', flex: 1 },
|
||||||
|
pct: { fontSize: 13, color: COLORS.muted },
|
||||||
|
track: { height: 8, borderRadius: 4, backgroundColor: '#e3e3e3', overflow: 'hidden' },
|
||||||
|
fill: { height: '100%', backgroundColor: COLORS.primary },
|
||||||
|
quickRow: { flexDirection: 'row', gap: 6 },
|
||||||
|
quickBtn: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.border,
|
||||||
|
},
|
||||||
|
quickText: { fontSize: 12, color: COLORS.muted, fontWeight: '600' },
|
||||||
|
});
|
||||||
+110
-26
@@ -9,16 +9,28 @@
|
|||||||
* Idempotente: cada operación lleva su uuid, así reenviar la cola es seguro.
|
* Idempotente: cada operación lleva su uuid, así reenviar la cola es seguro.
|
||||||
* Conflictos: last-write-wins en servidor; si responde `conflict`, aplicamos
|
* Conflictos: last-write-wins en servidor; si responde `conflict`, aplicamos
|
||||||
* el valor del servidor a la BD local y dejamos la op marcada para revisión.
|
* el valor del servidor a la BD local y dejamos la op marcada para revisión.
|
||||||
|
*
|
||||||
|
* Creaciones offline: las filas nacen con un id temporal negativo. Al recibir
|
||||||
|
* `applied` con `server_id` reconciliamos el id real y remapeamos las FKs de
|
||||||
|
* las operaciones hijas que aún referencian el id temporal (p. ej. una tarea
|
||||||
|
* creada sobre una incidencia que aún no existía en el servidor).
|
||||||
*/
|
*/
|
||||||
import { getBundle, sync as syncApi, uploadMedia } from '../api/endpoints';
|
import { getBundle, sync as syncApi, uploadMedia } from '../api/endpoints';
|
||||||
import { Operation, OperationResult, SyncEntity } from '../api/types';
|
import { Operation, OperationResult, SyncEntity } from '../api/types';
|
||||||
import { applyBundle, applyServerValue, getCursor } from '../db/repositories';
|
import {
|
||||||
|
applyBundle,
|
||||||
|
applyServerValue,
|
||||||
|
getCursor,
|
||||||
|
reconcileCreate,
|
||||||
|
} from '../db/repositories';
|
||||||
import {
|
import {
|
||||||
applyOperationResult,
|
applyOperationResult,
|
||||||
getPendingMedia,
|
getPendingMedia,
|
||||||
getPendingOperations,
|
getPendingOutbox,
|
||||||
markMediaError,
|
markMediaError,
|
||||||
markMediaSent,
|
markMediaSent,
|
||||||
|
PendingOp,
|
||||||
|
updateOutboxData,
|
||||||
} from '../db/outbox';
|
} from '../db/outbox';
|
||||||
|
|
||||||
const SYNC_BATCH = 100;
|
const SYNC_BATCH = 100;
|
||||||
@@ -32,6 +44,9 @@ const ENTITY_TABLE: Partial<Record<SyncEntity, string>> = {
|
|||||||
issue_comment: 'issue_comments',
|
issue_comment: 'issue_comments',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Campos que pueden contener un id temporal (negativo) a remapear. */
|
||||||
|
const FK_FIELDS = ['id', 'issue_id', 'feature_id', 'parent_id', 'layer_id', 'phase_id'] as const;
|
||||||
|
|
||||||
export interface SyncReport {
|
export interface SyncReport {
|
||||||
pushed: number;
|
pushed: number;
|
||||||
applied: number;
|
applied: number;
|
||||||
@@ -42,54 +57,123 @@ export interface SyncReport {
|
|||||||
pulled: boolean;
|
pulled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vacía el outbox de operaciones contra /sync. */
|
function negativeRefs(data: Record<string, unknown>): number[] {
|
||||||
async function pushOperations(report: SyncReport): Promise<void> {
|
const out: number[] = [];
|
||||||
// eslint-disable-next-line no-constant-condition
|
for (const f of FK_FIELDS) {
|
||||||
while (true) {
|
const v = data[f];
|
||||||
const ops = await getPendingOperations(SYNC_BATCH);
|
if (typeof v === 'number' && v < 0) out.push(v);
|
||||||
if (ops.length === 0) break;
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
const { results } = await syncApi(ops);
|
/** Reescribe en `data` los ids temporales ya resueltos. Devuelve si cambió algo. */
|
||||||
report.pushed += ops.length;
|
function rewriteRefs(data: Record<string, unknown>, idMap: Map<number, number>): boolean {
|
||||||
|
let changed = false;
|
||||||
const byUuid = new Map<string, Operation>(ops.map((o) => [o.uuid, o]));
|
for (const f of FK_FIELDS) {
|
||||||
for (const result of results) {
|
const v = data[f];
|
||||||
await processResult(result, byUuid.get(result.uuid), report);
|
if (typeof v === 'number' && v < 0 && idMap.has(v)) {
|
||||||
|
data[f] = idMap.get(v)!;
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
// Si nada quedó 'pending' que pudiéramos reintentar, salimos para no
|
function toOperation(op: PendingOp): Operation {
|
||||||
// bucear infinitamente con conflictos/errores.
|
return {
|
||||||
if (results.every((r) => r.status !== 'applied' && r.status !== 'duplicate')) {
|
entity: op.entity,
|
||||||
break;
|
op: op.op,
|
||||||
|
uuid: op.uuid,
|
||||||
|
client_updated_at: op.client_updated_at,
|
||||||
|
data: op.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persiste el remapeo de un id temporal en las operaciones pendientes (durabilidad). */
|
||||||
|
async function remapPendingOps(tempId: number, serverId: number): Promise<void> {
|
||||||
|
const pending = await getPendingOutbox(SYNC_BATCH * 4);
|
||||||
|
const single = new Map([[tempId, serverId]]);
|
||||||
|
for (const op of pending) {
|
||||||
|
if (rewriteRefs(op.data, single)) {
|
||||||
|
await updateOutboxData(op.uuid, op.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vacía el outbox de operaciones contra /sync, respetando dependencias. */
|
||||||
|
async function pushOperations(report: SyncReport): Promise<void> {
|
||||||
|
const idMap = new Map<number, number>(); // tempId -> serverId
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
const pending = await getPendingOutbox(SYNC_BATCH * 4);
|
||||||
|
if (pending.length === 0) break;
|
||||||
|
|
||||||
|
// Construir un lote: no incluir una op que dependa de un id temporal
|
||||||
|
// producido por un create de este mismo lote (aún sin id de servidor).
|
||||||
|
const producedInBatch = new Set<number>();
|
||||||
|
const batch: PendingOp[] = [];
|
||||||
|
for (const op of pending) {
|
||||||
|
rewriteRefs(op.data, idMap);
|
||||||
|
if (negativeRefs(op.data).some((r) => producedInBatch.has(r))) break;
|
||||||
|
batch.push(op);
|
||||||
|
if (op.op === 'create' && op.localId != null) producedInBatch.add(op.localId);
|
||||||
|
if (batch.length >= SYNC_BATCH) break;
|
||||||
|
}
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
|
const { results } = await syncApi(batch.map(toOperation));
|
||||||
|
report.pushed += batch.length;
|
||||||
|
|
||||||
|
const byUuid = new Map(batch.map((o) => [o.uuid, o]));
|
||||||
|
let progressed = false;
|
||||||
|
for (const result of results) {
|
||||||
|
progressed = (await processResult(result, byUuid.get(result.uuid), report, idMap)) || progressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si ningún ítem avanzó (todo conflicto/error), evitamos un bucle infinito.
|
||||||
|
if (!progressed) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Procesa un resultado; devuelve true si "avanzó" (applied/duplicate). */
|
||||||
async function processResult(
|
async function processResult(
|
||||||
result: OperationResult,
|
result: OperationResult,
|
||||||
op: Operation | undefined,
|
op: PendingOp | undefined,
|
||||||
report: SyncReport,
|
report: SyncReport,
|
||||||
): Promise<void> {
|
idMap: Map<number, number>,
|
||||||
|
): Promise<boolean> {
|
||||||
await applyOperationResult(result);
|
await applyOperationResult(result);
|
||||||
|
|
||||||
if (result.status === 'applied' || result.status === 'duplicate') {
|
if (result.status === 'applied' || result.status === 'duplicate') {
|
||||||
report.applied += 1;
|
report.applied += 1;
|
||||||
} else if (result.status === 'conflict') {
|
if (op && op.op === 'create' && op.localId != null && result.server_id != null) {
|
||||||
|
const tempId = await reconcileCreate(op.entity, op.uuid, result.server_id);
|
||||||
|
if (tempId != null && tempId !== result.server_id) {
|
||||||
|
idMap.set(tempId, result.server_id);
|
||||||
|
await remapPendingOps(tempId, result.server_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'conflict') {
|
||||||
report.conflicts += 1;
|
report.conflicts += 1;
|
||||||
// Volcamos el valor del servidor a la BD local (last-write-wins servidor).
|
|
||||||
if (op && result.server) {
|
if (op && result.server) {
|
||||||
const table = ENTITY_TABLE[op.entity];
|
const table = ENTITY_TABLE[op.entity];
|
||||||
const id = (op.data as { id?: number }).id;
|
const id = (op.data as { id?: number }).id;
|
||||||
if (table && id != null) {
|
if (table && id != null && id > 0) {
|
||||||
await applyServerValue(table, id, result.server);
|
await applyServerValue(table, id, result.server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
return false;
|
||||||
report.errors += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
report.errors += 1;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sube los ficheros pendientes a /media. */
|
/** Sube los ficheros pendientes a /media (solo los de padres ya sincronizados). */
|
||||||
async function pushMedia(report: SyncReport): Promise<void> {
|
async function pushMedia(report: SyncReport): Promise<void> {
|
||||||
const pending = await getPendingMedia();
|
const pending = await getPendingMedia();
|
||||||
for (const m of pending) {
|
for (const m of pending) {
|
||||||
|
|||||||
+55
-8
@@ -8,6 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
import { getDb } from '../db/database';
|
import { getDb } from '../db/database';
|
||||||
import { enqueueOperation } from '../db/outbox';
|
import { enqueueOperation } from '../db/outbox';
|
||||||
|
import { insertLocalCreate } from '../db/repositories';
|
||||||
|
import { newUuid, nextTempId, nowIso } from './uuid';
|
||||||
import {
|
import {
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
IssueStatus,
|
IssueStatus,
|
||||||
@@ -60,7 +62,7 @@ export async function updateFeature(input: {
|
|||||||
|
|
||||||
// ----- inspection.create (append-only) -----
|
// ----- inspection.create (append-only) -----
|
||||||
|
|
||||||
export function createInspection(input: {
|
export async function createInspection(input: {
|
||||||
feature_id: number;
|
feature_id: number;
|
||||||
template_id?: number;
|
template_id?: number;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
@@ -68,12 +70,25 @@ export function createInspection(input: {
|
|||||||
result?: string;
|
result?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
return enqueueOperation('inspection', 'create', { ...input });
|
const uuid = newUuid();
|
||||||
|
const tempId = nextTempId();
|
||||||
|
await insertLocalCreate('inspection', tempId, uuid, {
|
||||||
|
feature_id: input.feature_id,
|
||||||
|
template_id: input.template_id ?? null,
|
||||||
|
data: input.data ? JSON.stringify(input.data) : null,
|
||||||
|
status: input.status ?? null,
|
||||||
|
result: input.result ?? null,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
created_at: nowIso(),
|
||||||
|
updated_at: nowIso(),
|
||||||
|
dirty: 1,
|
||||||
|
});
|
||||||
|
return enqueueOperation('inspection', 'create', { ...input }, { uuid, localId: tempId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- issues -----
|
// ----- issues -----
|
||||||
|
|
||||||
export function createIssue(input: {
|
export async function createIssue(input: {
|
||||||
project_id: number;
|
project_id: number;
|
||||||
feature_id?: number;
|
feature_id?: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -82,7 +97,20 @@ export function createIssue(input: {
|
|||||||
status?: IssueStatus;
|
status?: IssueStatus;
|
||||||
type?: IssueType;
|
type?: IssueType;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
return enqueueOperation('issue', 'create', { ...input });
|
const uuid = newUuid();
|
||||||
|
const tempId = nextTempId();
|
||||||
|
await insertLocalCreate('issue', tempId, uuid, {
|
||||||
|
project_id: input.project_id,
|
||||||
|
feature_id: input.feature_id ?? null,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
priority: input.priority ?? null,
|
||||||
|
status: input.status ?? 'open',
|
||||||
|
type: input.type ?? null,
|
||||||
|
updated_at: nowIso(),
|
||||||
|
dirty: 1,
|
||||||
|
});
|
||||||
|
return enqueueOperation('issue', 'create', { ...input }, { uuid, localId: tempId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateIssue(input: {
|
export async function updateIssue(input: {
|
||||||
@@ -125,14 +153,25 @@ export async function updateIssue(input: {
|
|||||||
|
|
||||||
// ----- issue tasks -----
|
// ----- issue tasks -----
|
||||||
|
|
||||||
export function createIssueTask(input: {
|
export async function createIssueTask(input: {
|
||||||
issue_id: number;
|
issue_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
assigned_to?: number;
|
assigned_to?: number;
|
||||||
due_date?: string;
|
due_date?: string;
|
||||||
is_done?: boolean;
|
is_done?: boolean;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
return enqueueOperation('issue_task', 'create', { ...input });
|
const uuid = newUuid();
|
||||||
|
const tempId = nextTempId();
|
||||||
|
await insertLocalCreate('issue_task', tempId, uuid, {
|
||||||
|
issue_id: input.issue_id,
|
||||||
|
title: input.title,
|
||||||
|
is_done: input.is_done ? 1 : 0,
|
||||||
|
assigned_to: input.assigned_to ?? null,
|
||||||
|
due_date: input.due_date ?? null,
|
||||||
|
updated_at: nowIso(),
|
||||||
|
dirty: 1,
|
||||||
|
});
|
||||||
|
return enqueueOperation('issue_task', 'create', { ...input }, { uuid, localId: tempId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateIssueTask(input: {
|
export async function updateIssueTask(input: {
|
||||||
@@ -155,9 +194,17 @@ export async function updateIssueTask(input: {
|
|||||||
|
|
||||||
// ----- issue comments (append-only) -----
|
// ----- issue comments (append-only) -----
|
||||||
|
|
||||||
export function createIssueComment(input: {
|
export async function createIssueComment(input: {
|
||||||
issue_id: number;
|
issue_id: number;
|
||||||
body: string;
|
body: string;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
return enqueueOperation('issue_comment', 'create', { ...input });
|
const uuid = newUuid();
|
||||||
|
const tempId = nextTempId();
|
||||||
|
await insertLocalCreate('issue_comment', tempId, uuid, {
|
||||||
|
issue_id: input.issue_id,
|
||||||
|
body: input.body,
|
||||||
|
created_at: nowIso(),
|
||||||
|
updated_at: nowIso(),
|
||||||
|
});
|
||||||
|
return enqueueOperation('issue_comment', 'create', { ...input }, { uuid, localId: tempId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Auto-sync mientras hay un proyecto abierto. Dispara una sincronización:
|
||||||
|
* - al recuperar la conexión,
|
||||||
|
* - al volver la app a primer plano,
|
||||||
|
* - periódicamente (intervalo suave),
|
||||||
|
* con un candado para no solapar ciclos. La sincronización en segundo plano con
|
||||||
|
* la app cerrada queda fuera de v1.
|
||||||
|
*/
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { AppState } from 'react-native';
|
||||||
|
import { isOnline } from '../net/connectivity';
|
||||||
|
|
||||||
|
const INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
export function useAutoSync(enabled: boolean, run: () => Promise<void>) {
|
||||||
|
const running = useRef(false);
|
||||||
|
// Mantener la última `run` sin re-suscribir los listeners en cada render.
|
||||||
|
const runRef = useRef(run);
|
||||||
|
runRef.current = run;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const trigger = async () => {
|
||||||
|
if (running.current) return;
|
||||||
|
if (!(await isOnline())) return;
|
||||||
|
running.current = true;
|
||||||
|
try {
|
||||||
|
await runRef.current();
|
||||||
|
} catch {
|
||||||
|
// silencioso: el estado/errores se reflejan en el outbox y la barra
|
||||||
|
} finally {
|
||||||
|
running.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Al recuperar conexión.
|
||||||
|
const unsubNet = NetInfo.addEventListener((state) => {
|
||||||
|
if (state.isConnected && state.isInternetReachable !== false) void trigger();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Al volver a primer plano.
|
||||||
|
const sub = AppState.addEventListener('change', (s) => {
|
||||||
|
if (s === 'active') void trigger();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intervalo suave.
|
||||||
|
const id = setInterval(() => void trigger(), INTERVAL_MS);
|
||||||
|
|
||||||
|
// Un primer intento al montar.
|
||||||
|
void trigger();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubNet();
|
||||||
|
sub.remove();
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
}
|
||||||
@@ -9,3 +9,16 @@ export function newUuid(): string {
|
|||||||
export function nowIso(): string {
|
export function nowIso(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tempCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Id local temporal para filas creadas offline. Siempre **negativo** para no
|
||||||
|
* colisionar nunca con los ids (positivos) del servidor; al sincronizar se
|
||||||
|
* sustituye por el id real (ver reconcileCreate).
|
||||||
|
*/
|
||||||
|
export function nextTempId(): number {
|
||||||
|
tempCounter = (tempCounter + 1) % 1000;
|
||||||
|
return -(Date.now() * 1000 + tempCounter);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Mapa de features. Dibuja la geometría GeoJSON del proyecto (puntos, líneas,
|
||||||
|
* polígonos) sobre Google Maps y permite seleccionar una feature tocándola.
|
||||||
|
*
|
||||||
|
* Nota: las tiles de Google Maps requieren conexión; la geometría sí se dibuja
|
||||||
|
* sin red. Requiere una API key de Google Maps (ver app.config.js / README).
|
||||||
|
*/
|
||||||
|
import * as Location from 'expo-location';
|
||||||
|
import React, { useMemo, useRef } from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import MapView, { Marker, Polygon, Polyline, PROVIDER_GOOGLE } from 'react-native-maps';
|
||||||
|
import { Feature } from '../api/types';
|
||||||
|
import { COLORS } from './components';
|
||||||
|
import { geometryToShapes, LatLng, regionFor } from './geojson';
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
pending: '#9aa0a6',
|
||||||
|
in_progress: '#1f6f43',
|
||||||
|
completed: '#2e7d32',
|
||||||
|
blocked: '#b00020',
|
||||||
|
};
|
||||||
|
|
||||||
|
function colorFor(status?: string): string {
|
||||||
|
return (status && STATUS_COLOR[status]) || COLORS.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeatureMap({
|
||||||
|
features,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
features: Feature[];
|
||||||
|
selectedId: number | null;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
}) {
|
||||||
|
const mapRef = useRef<MapView>(null);
|
||||||
|
|
||||||
|
const { shaped, region } = useMemo(() => {
|
||||||
|
const all: LatLng[] = [];
|
||||||
|
const shaped = features.map((f) => {
|
||||||
|
const shapes = geometryToShapes(f.geometry);
|
||||||
|
all.push(...shapes.points, ...shapes.lines.flat(), ...shapes.polygons.flat());
|
||||||
|
return { feature: f, shapes };
|
||||||
|
});
|
||||||
|
return { shaped, region: regionFor(all) };
|
||||||
|
}, [features]);
|
||||||
|
|
||||||
|
const recenter = async () => {
|
||||||
|
const perm = await Location.requestForegroundPermissionsAsync();
|
||||||
|
if (!perm.granted) return;
|
||||||
|
const pos = await Location.getCurrentPositionAsync({});
|
||||||
|
mapRef.current?.animateToRegion({
|
||||||
|
latitude: pos.coords.latitude,
|
||||||
|
longitude: pos.coords.longitude,
|
||||||
|
latitudeDelta: 0.01,
|
||||||
|
longitudeDelta: 0.01,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!region) {
|
||||||
|
return (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={{ color: COLORS.muted }}>Las features no tienen geometría.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<MapView
|
||||||
|
ref={mapRef}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
provider={PROVIDER_GOOGLE}
|
||||||
|
initialRegion={region}
|
||||||
|
showsUserLocation
|
||||||
|
>
|
||||||
|
{shaped.map(({ feature, shapes }) => {
|
||||||
|
const color = colorFor(feature.status);
|
||||||
|
const selected = feature.id === selectedId;
|
||||||
|
const stroke = selected ? '#000' : color;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={feature.id}>
|
||||||
|
{shapes.points.map((p, i) => (
|
||||||
|
<Marker
|
||||||
|
key={`pt${feature.id}-${i}`}
|
||||||
|
coordinate={p}
|
||||||
|
pinColor={color}
|
||||||
|
title={feature.name}
|
||||||
|
onPress={() => onSelect(feature.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{shapes.lines.map((line, i) => (
|
||||||
|
<Polyline
|
||||||
|
key={`ln${feature.id}-${i}`}
|
||||||
|
coordinates={line}
|
||||||
|
strokeColor={stroke}
|
||||||
|
strokeWidth={selected ? 5 : 3}
|
||||||
|
tappable
|
||||||
|
onPress={() => onSelect(feature.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{shapes.polygons.map((poly, i) => (
|
||||||
|
<Polygon
|
||||||
|
key={`pg${feature.id}-${i}`}
|
||||||
|
coordinates={poly}
|
||||||
|
strokeColor={stroke}
|
||||||
|
fillColor={`${color}55`}
|
||||||
|
strokeWidth={selected ? 4 : 2}
|
||||||
|
tappable
|
||||||
|
onPress={() => onSelect(feature.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MapView>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.locBtn} onPress={() => void recenter()}>
|
||||||
|
<Text style={styles.locIcon}>◎</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1 },
|
||||||
|
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
||||||
|
locBtn: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
},
|
||||||
|
locIcon: { fontSize: 22, color: COLORS.primary },
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Layout maestro-detalle adaptativo.
|
||||||
|
*
|
||||||
|
* - **Tablet**: dos paneles lado a lado. La selección vive en estado local; al
|
||||||
|
* tocar un ítem se muestra su detalle en el panel derecho (no navega).
|
||||||
|
* - **Móvil**: solo el panel maestro; al tocar un ítem se invoca `onSelectPhone`
|
||||||
|
* (la pantalla lo conecta con `navigation.navigate`).
|
||||||
|
*/
|
||||||
|
import React, { ReactNode, useState } from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useLayout } from './responsive';
|
||||||
|
|
||||||
|
interface MasterDetailProps {
|
||||||
|
/** Pinta la lista. Recibe la selección actual (tablet) y el callback de selección. */
|
||||||
|
renderMaster: (args: {
|
||||||
|
selectedId: number | null;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
}) => ReactNode;
|
||||||
|
/** Pinta el detalle de un id (solo se usa en tablet). */
|
||||||
|
renderDetail: (id: number) => ReactNode;
|
||||||
|
/** En móvil: qué hacer al seleccionar (normalmente navegar al detalle). */
|
||||||
|
onSelectPhone: (id: number) => void;
|
||||||
|
/** Texto/elemento cuando aún no hay selección (tablet). */
|
||||||
|
placeholder?: ReactNode;
|
||||||
|
/** Proporción del panel maestro en tablet (0–1). */
|
||||||
|
masterFlex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MasterDetail({
|
||||||
|
renderMaster,
|
||||||
|
renderDetail,
|
||||||
|
onSelectPhone,
|
||||||
|
placeholder,
|
||||||
|
masterFlex = 0.38,
|
||||||
|
}: MasterDetailProps) {
|
||||||
|
const { isTablet } = useLayout();
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
if (!isTablet) {
|
||||||
|
return <>{renderMaster({ selectedId: null, onSelect: onSelectPhone })}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.master, { flex: masterFlex }]}>
|
||||||
|
{renderMaster({ selectedId, onSelect: setSelectedId })}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.detail, { flex: 1 - masterFlex }]}>
|
||||||
|
{selectedId != null ? (
|
||||||
|
renderDetail(selectedId)
|
||||||
|
) : (
|
||||||
|
<View style={styles.placeholder}>
|
||||||
|
{placeholder ?? <Text style={styles.placeholderText}>Selecciona un elemento</Text>}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: { flex: 1, flexDirection: 'row' },
|
||||||
|
master: { borderRightWidth: StyleSheet.hairlineWidth, borderColor: '#ddd' },
|
||||||
|
detail: { flex: 1 },
|
||||||
|
placeholder: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
||||||
|
placeholderText: { color: '#888', fontSize: 15 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Tira de fotos de un registro (feature/issue/issue_task/issue_comment).
|
||||||
|
* Muestra las ya sincronizadas (tabla `media`, con url) y las locales en cola
|
||||||
|
* (`media_outbox`), y permite añadir nuevas desde cámara o galería (offline).
|
||||||
|
*/
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Alert, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { Media, MediaParentEntity } from '../api/types';
|
||||||
|
import { absoluteUrl } from '../config';
|
||||||
|
import { enqueueMedia, getMediaOutboxFor, MediaOutboxRow } from '../db/outbox';
|
||||||
|
import { getMediaFor } from '../db/repositories';
|
||||||
|
import { COLORS } from './components';
|
||||||
|
|
||||||
|
export function MediaStrip({
|
||||||
|
parentEntity,
|
||||||
|
parentId,
|
||||||
|
canUpload,
|
||||||
|
}: {
|
||||||
|
parentEntity: MediaParentEntity;
|
||||||
|
parentId: number;
|
||||||
|
canUpload: boolean;
|
||||||
|
}) {
|
||||||
|
const [synced, setSynced] = useState<Media[]>([]);
|
||||||
|
const [pending, setPending] = useState<MediaOutboxRow[]>([]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const [s, p] = await Promise.all([
|
||||||
|
getMediaFor(parentEntity, parentId),
|
||||||
|
getMediaOutboxFor(parentEntity, parentId),
|
||||||
|
]);
|
||||||
|
setSynced(s);
|
||||||
|
setPending(p);
|
||||||
|
}, [parentEntity, parentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const add = useCallback(
|
||||||
|
async (source: 'camera' | 'library') => {
|
||||||
|
const perm =
|
||||||
|
source === 'camera'
|
||||||
|
? await ImagePicker.requestCameraPermissionsAsync()
|
||||||
|
: await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!perm.granted) {
|
||||||
|
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result =
|
||||||
|
source === 'camera'
|
||||||
|
? await ImagePicker.launchCameraAsync({ quality: 0.7 })
|
||||||
|
: await ImagePicker.launchImageLibraryAsync({ quality: 0.7, mediaTypes: 'images' });
|
||||||
|
if (result.canceled || !result.assets?.length) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
await enqueueMedia({
|
||||||
|
parentEntity,
|
||||||
|
parentId,
|
||||||
|
localUri: asset.uri,
|
||||||
|
fileName: asset.fileName ?? undefined,
|
||||||
|
mimeType: asset.mimeType ?? 'image/jpeg',
|
||||||
|
category: 'image',
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[parentEntity, parentId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAdd = useCallback(() => {
|
||||||
|
Alert.alert('Añadir foto', undefined, [
|
||||||
|
{ text: 'Cámara', onPress: () => void add('camera') },
|
||||||
|
{ text: 'Galería', onPress: () => void add('library') },
|
||||||
|
{ text: 'Cancelar', style: 'cancel' },
|
||||||
|
]);
|
||||||
|
}, [add]);
|
||||||
|
|
||||||
|
if (synced.length === 0 && pending.length === 0 && !canUpload) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
|
||||||
|
{canUpload && (
|
||||||
|
<TouchableOpacity style={styles.addBtn} onPress={onAdd}>
|
||||||
|
<Text style={styles.addPlus}>+</Text>
|
||||||
|
<Text style={styles.addText}>Foto</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{pending.map((m) => (
|
||||||
|
<View key={m.uuid} style={styles.thumbWrap}>
|
||||||
|
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
|
||||||
|
<View style={[styles.tag, { backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn }]}>
|
||||||
|
<Text style={styles.tagText}>{m.status === 'error' ? 'error' : 'en cola'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{synced.map((m) => (
|
||||||
|
<View key={`s${m.id}`} style={styles.thumbWrap}>
|
||||||
|
<Image source={{ uri: absoluteUrl(m.url) }} style={styles.thumb} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { marginVertical: 8 },
|
||||||
|
row: { gap: 8, paddingRight: 8 },
|
||||||
|
addBtn: {
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.primary,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
addPlus: { color: COLORS.primary, fontSize: 22, fontWeight: '700' },
|
||||||
|
addText: { color: COLORS.primary, fontSize: 11 },
|
||||||
|
thumbWrap: { width: 72, height: 72, borderRadius: 8, overflow: 'hidden', backgroundColor: COLORS.bg },
|
||||||
|
thumb: { width: '100%', height: '100%' },
|
||||||
|
tag: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingVertical: 1, alignItems: 'center' },
|
||||||
|
tagText: { color: '#fff', fontSize: 9, fontWeight: '700' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Primitivas de UI compartidas (botones, tarjetas, badges, campos).
|
||||||
|
* Estilo sobrio, pensado para uso en campo (targets grandes, buen contraste).
|
||||||
|
*/
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TextInputProps,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
export const COLORS = {
|
||||||
|
primary: '#1f6f43',
|
||||||
|
warn: '#8a6d00',
|
||||||
|
danger: '#b00020',
|
||||||
|
muted: '#666',
|
||||||
|
border: '#ddd',
|
||||||
|
bg: '#f4f4f4',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ISSUE_STATUS_COLOR: Record<string, string> = {
|
||||||
|
open: COLORS.danger,
|
||||||
|
in_review: COLORS.warn,
|
||||||
|
resolved: COLORS.primary,
|
||||||
|
closed: COLORS.muted,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ISSUE_PRIORITY_COLOR: Record<string, string> = {
|
||||||
|
low: COLORS.muted,
|
||||||
|
medium: COLORS.primary,
|
||||||
|
high: COLORS.warn,
|
||||||
|
critical: COLORS.danger,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Badge({ label, color }: { label: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.badge, { backgroundColor: color }]}>
|
||||||
|
<Text style={styles.badgeText}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrimaryButton({
|
||||||
|
title,
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
|
variant = 'primary',
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
variant?: 'primary' | 'danger' | 'ghost';
|
||||||
|
}) {
|
||||||
|
const bg =
|
||||||
|
variant === 'danger' ? COLORS.danger : variant === 'ghost' ? 'transparent' : COLORS.primary;
|
||||||
|
const fg = variant === 'ghost' ? COLORS.primary : '#fff';
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
{ backgroundColor: bg },
|
||||||
|
variant === 'ghost' && styles.buttonGhost,
|
||||||
|
(disabled || loading) && styles.buttonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={fg} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.buttonText, { color: fg }]}>{title}</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}) {
|
||||||
|
return <View style={[styles.card, style]}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionTitle({ children }: { children: ReactNode }) {
|
||||||
|
return <Text style={styles.sectionTitle}>{children}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ text }: { text: string }) {
|
||||||
|
return <Text style={styles.empty}>{text}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: TextInputProps & { label: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.fieldLabel}>{label}</Text>
|
||||||
|
<TextInput style={styles.input} placeholderTextColor="#aaa" {...props} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selector simple de opciones (chips). */
|
||||||
|
export function ChipSelect<T extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: T | undefined;
|
||||||
|
options: readonly T[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.fieldLabel}>{label}</Text>
|
||||||
|
<View style={styles.chips}>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const active = opt === value;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={opt}
|
||||||
|
style={[styles.chip, active && styles.chipActive]}
|
||||||
|
onPress={() => onChange(opt)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.chipText, active && styles.chipTextActive]}>{opt}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, alignSelf: 'flex-start' },
|
||||||
|
badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
||||||
|
button: {
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 13,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
buttonGhost: { borderWidth: 1, borderColor: COLORS.primary },
|
||||||
|
buttonDisabled: { opacity: 0.5 },
|
||||||
|
buttonText: { fontSize: 15, fontWeight: '700' },
|
||||||
|
card: {
|
||||||
|
backgroundColor: COLORS.bg,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
sectionTitle: { fontSize: 16, fontWeight: '700', marginTop: 8, marginBottom: 4 },
|
||||||
|
empty: { color: '#888', textAlign: 'center', marginTop: 24 },
|
||||||
|
field: { marginBottom: 12 },
|
||||||
|
fieldLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 4, fontWeight: '600' },
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||||
|
chip: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.border,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
chipActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
|
||||||
|
chipText: { fontSize: 13, color: COLORS.muted },
|
||||||
|
chipTextActive: { color: '#fff', fontWeight: '700' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Conversión de geometría GeoJSON a coordenadas de react-native-maps.
|
||||||
|
* GeoJSON usa [lng, lat]; react-native-maps usa { latitude, longitude }.
|
||||||
|
*/
|
||||||
|
export interface LatLng {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShapeSet {
|
||||||
|
points: LatLng[];
|
||||||
|
lines: LatLng[][];
|
||||||
|
polygons: LatLng[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLatLng(pos: unknown): LatLng | null {
|
||||||
|
if (Array.isArray(pos) && pos.length >= 2 && typeof pos[0] === 'number' && typeof pos[1] === 'number') {
|
||||||
|
return { longitude: pos[0], latitude: pos[1] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ring(coords: unknown): LatLng[] {
|
||||||
|
if (!Array.isArray(coords)) return [];
|
||||||
|
return coords.map(toLatLng).filter((c): c is LatLng => c !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aplana una geometría GeoJSON a conjuntos de puntos/líneas/polígonos. */
|
||||||
|
export function geometryToShapes(geometry: unknown): ShapeSet {
|
||||||
|
const out: ShapeSet = { points: [], lines: [], polygons: [] };
|
||||||
|
const g = geometry as { type?: string; coordinates?: unknown } | null;
|
||||||
|
if (!g || !g.type) return out;
|
||||||
|
|
||||||
|
switch (g.type) {
|
||||||
|
case 'Point': {
|
||||||
|
const p = toLatLng(g.coordinates);
|
||||||
|
if (p) out.points.push(p);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'MultiPoint':
|
||||||
|
out.points.push(...ring(g.coordinates));
|
||||||
|
break;
|
||||||
|
case 'LineString':
|
||||||
|
out.lines.push(ring(g.coordinates));
|
||||||
|
break;
|
||||||
|
case 'MultiLineString':
|
||||||
|
if (Array.isArray(g.coordinates)) g.coordinates.forEach((l) => out.lines.push(ring(l)));
|
||||||
|
break;
|
||||||
|
case 'Polygon':
|
||||||
|
// Solo el anillo exterior (índice 0).
|
||||||
|
if (Array.isArray(g.coordinates) && g.coordinates[0]) out.polygons.push(ring(g.coordinates[0]));
|
||||||
|
break;
|
||||||
|
case 'MultiPolygon':
|
||||||
|
if (Array.isArray(g.coordinates)) {
|
||||||
|
g.coordinates.forEach((poly) => {
|
||||||
|
if (Array.isArray(poly) && poly[0]) out.polygons.push(ring(poly[0]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calcula una región que englobe todas las coordenadas (para el encuadre inicial). */
|
||||||
|
export function regionFor(all: LatLng[]): {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
latitudeDelta: number;
|
||||||
|
longitudeDelta: number;
|
||||||
|
} | null {
|
||||||
|
if (all.length === 0) return null;
|
||||||
|
let minLat = all[0].latitude;
|
||||||
|
let maxLat = all[0].latitude;
|
||||||
|
let minLng = all[0].longitude;
|
||||||
|
let maxLng = all[0].longitude;
|
||||||
|
for (const c of all) {
|
||||||
|
minLat = Math.min(minLat, c.latitude);
|
||||||
|
maxLat = Math.max(maxLat, c.latitude);
|
||||||
|
minLng = Math.min(minLng, c.longitude);
|
||||||
|
maxLng = Math.max(maxLng, c.longitude);
|
||||||
|
}
|
||||||
|
const latitudeDelta = Math.max((maxLat - minLat) * 1.4, 0.01);
|
||||||
|
const longitudeDelta = Math.max((maxLng - minLng) * 1.4, 0.01);
|
||||||
|
return {
|
||||||
|
latitude: (minLat + maxLat) / 2,
|
||||||
|
longitude: (minLng + maxLng) / 2,
|
||||||
|
latitudeDelta,
|
||||||
|
longitudeDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Capa responsive: un único punto donde se decide cómo se comporta la UI según
|
||||||
|
* el tamaño del dispositivo (móvil vs tablet). Sin librerías externas.
|
||||||
|
*/
|
||||||
|
import { useWindowDimensions } from 'react-native';
|
||||||
|
|
||||||
|
/** Umbral de "tablet": ancho disponible >= 900dp (tablet en horizontal o grande). */
|
||||||
|
export const TABLET_BREAKPOINT = 900;
|
||||||
|
|
||||||
|
export interface Layout {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
isTablet: boolean;
|
||||||
|
isLandscape: boolean;
|
||||||
|
/** Columnas sugeridas para rejillas de tarjetas. */
|
||||||
|
columns: number;
|
||||||
|
/** Padding base de pantalla. */
|
||||||
|
gutter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLayout(): Layout {
|
||||||
|
const { width, height } = useWindowDimensions();
|
||||||
|
const isLandscape = width > height;
|
||||||
|
const isTablet = width >= TABLET_BREAKPOINT;
|
||||||
|
|
||||||
|
let columns = 1;
|
||||||
|
if (width >= 1200) columns = 3;
|
||||||
|
else if (width >= TABLET_BREAKPOINT) columns = 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
isTablet,
|
||||||
|
isLandscape,
|
||||||
|
columns,
|
||||||
|
gutter: isTablet ? 24 : 16,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user