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) ·
|
||||
protocolo de sync: [`docs/MOBILE_SYNC_PROTOCOL.md`](docs/MOBILE_SYNC_PROTOCOL.md).
|
||||
|
||||
## Arranque
|
||||
## Arranque (Android)
|
||||
|
||||
La app usa módulos nativos (expo-sqlite, expo-secure-store, react-native-maps) que **no
|
||||
funcionan en Expo Go**: hay que usar un *development build* o un APK.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start # abre Expo; pulsa 'a' (Android) / 'i' (iOS) / 'w' (web)
|
||||
npm run typecheck # comprobación de tipos
|
||||
npm run typecheck # comprobación de tipos
|
||||
|
||||
# Development build (emulador o dispositivo con depuración USB):
|
||||
npx expo run:android # compila e instala un dev client con hot reload
|
||||
```
|
||||
|
||||
> **Backend local (XAMPP):** ajusta `BASE_URL` en [`src/config.ts`](src/config.ts).
|
||||
> Desde emulador Android usa `http://10.0.2.2/...`; desde dispositivo físico, la IP LAN del PC.
|
||||
> `localhost` apunta al propio teléfono, no al PC.
|
||||
|
||||
### APK para repartir (sideload)
|
||||
|
||||
Con [EAS Build](https://docs.expo.dev/build/introduction/) (nube de Expo, no necesita Mac):
|
||||
|
||||
```bash
|
||||
npm i -g eas-cli # una vez
|
||||
eas login # cuenta Expo
|
||||
eas build -p android --profile preview # genera un .apk (distribution: internal)
|
||||
```
|
||||
|
||||
Al terminar, EAS da un enlace de descarga del `.apk`; instálalo en el dispositivo
|
||||
(habilitando "orígenes desconocidos"). Perfiles en [`eas.json`](eas.json):
|
||||
`development` (dev client), `preview` (APK interno), `production` (AAB, para Play más adelante).
|
||||
|
||||
### Mapa (Google Maps)
|
||||
|
||||
La sección Features incluye un mapa (react-native-maps) que dibuja la geometría GeoJSON.
|
||||
Requiere una **API key de Google Maps (Android)**, que se inyecta vía variable de entorno
|
||||
`GOOGLE_MAPS_API_KEY` en [`app.config.js`](app.config.js) — **no se commitea**:
|
||||
|
||||
```bash
|
||||
# local
|
||||
export GOOGLE_MAPS_API_KEY=AIza... # (PowerShell: $env:GOOGLE_MAPS_API_KEY="AIza...")
|
||||
npx expo run:android
|
||||
|
||||
# EAS: guárdala como secreto
|
||||
eas secret:create --name GOOGLE_MAPS_API_KEY --value AIza...
|
||||
```
|
||||
|
||||
Sin key, la app funciona pero el mapa no carga tiles (la lista de features sí). Las tiles
|
||||
necesitan conexión; la geometría se dibuja también sin red.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
"slug": "avante-movil",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"package": "group.mai.avante",
|
||||
"versionCode": 1,
|
||||
"permissions": [
|
||||
"android.permission.INTERNET",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.ACCESS_COARSE_LOCATION"
|
||||
],
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
@@ -23,7 +31,21 @@
|
||||
},
|
||||
"plugins": [
|
||||
"expo-sqlite",
|
||||
"expo-secure-store"
|
||||
"expo-secure-store",
|
||||
"expo-build-properties",
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Avante usa tus fotos para adjuntarlas a incidencias e inspecciones.",
|
||||
"cameraPermission": "Avante usa la cámara para fotografiar el avance de obra e incidencias."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationWhenInUsePermission": "Avante usa tu ubicación para situar avances e incidencias en el mapa."
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "avante_init",
|
||||
"name": "avante-movil",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@react-native-community/netinfo": "12.0.1",
|
||||
"@react-navigation/native": "^7.3.3",
|
||||
"@react-navigation/native-stack": "^7.17.5",
|
||||
"expo": "~56.0.12",
|
||||
"expo-build-properties": "~56.0.19",
|
||||
"expo-crypto": "~56.0.4",
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-image-picker": "~56.0.18",
|
||||
"expo-location": "~56.0.18",
|
||||
"expo-secure-store": "~56.0.4",
|
||||
"expo-sqlite": "~56.0.5",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-maps": "1.27.2",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2"
|
||||
},
|
||||
@@ -1790,6 +1794,12 @@
|
||||
"integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -2777,6 +2787,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-build-properties": {
|
||||
"version": "56.0.19",
|
||||
"resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.19.tgz",
|
||||
"integrity": "sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/schema-utils": "^56.0.0",
|
||||
"resolve-from": "^5.0.0",
|
||||
"semver": "^7.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-crypto": {
|
||||
"version": "56.0.4",
|
||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz",
|
||||
@@ -2796,6 +2820,39 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-loader": {
|
||||
"version": "56.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-56.0.3.tgz",
|
||||
"integrity": "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-picker": {
|
||||
"version": "56.0.18",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.18.tgz",
|
||||
"integrity": "sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-image-loader": "~56.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-location": {
|
||||
"version": "56.0.18",
|
||||
"resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.18.tgz",
|
||||
"integrity": "sha512-6xP0UwGy8a7EEHAMeigYAp3HNo3yWHAg05tVPUfwrOWepWPpFXmjsfUBUxQdkpfpjddJ9r+f4PplxZqKI0LtjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/image-utils": "^0.10.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "56.0.16",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.16.tgz",
|
||||
@@ -5248,6 +5305,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-maps": {
|
||||
"version": "1.27.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.27.2.tgz",
|
||||
"integrity": "sha512-VKr+xZ2RZGHHJlY6KhlafvGSmK0dq/tUu5uhfJ7K9rwN5pUdubdugzMKGDU/16lXmQSg7xbClKhRctj3Pm5F5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "^7946.0.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 18.3.1",
|
||||
"react-native": ">= 0.76.0",
|
||||
"react-native-web": ">= 0.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-safe-area-context": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
|
||||
|
||||
@@ -7,13 +7,17 @@
|
||||
"@react-navigation/native": "^7.3.3",
|
||||
"@react-navigation/native-stack": "^7.17.5",
|
||||
"expo": "~56.0.12",
|
||||
"expo-build-properties": "~56.0.19",
|
||||
"expo-crypto": "~56.0.4",
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-image-picker": "~56.0.18",
|
||||
"expo-location": "~56.0.18",
|
||||
"expo-secure-store": "~56.0.4",
|
||||
"expo-sqlite": "~56.0.5",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-maps": "1.27.2",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2"
|
||||
},
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { OutboxCounts } from '../db/outbox';
|
||||
import { useIsOnline } from '../net/connectivity';
|
||||
|
||||
export function SyncStatusBar({
|
||||
counts,
|
||||
syncing,
|
||||
onPress,
|
||||
}: {
|
||||
counts: OutboxCounts;
|
||||
syncing: boolean;
|
||||
onPress?: () => void;
|
||||
}) {
|
||||
const online = useIsOnline();
|
||||
const pendientes = counts.pending + counts.mediaPending;
|
||||
const hasProblems = counts.conflict > 0 || counts.error > 0;
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<View style={[styles.bar, { backgroundColor: online ? '#1f6f43' : '#8a6d00' }]}>
|
||||
{syncing && <ActivityIndicator color="#fff" size="small" />}
|
||||
<Text style={styles.text}>
|
||||
@@ -22,8 +25,18 @@ export function SyncStatusBar({
|
||||
{counts.conflict > 0 ? ` · ${counts.conflict} conflicto(s)` : ''}
|
||||
{counts.error > 0 ? ` · ${counts.error} error(es)` : ''}
|
||||
</Text>
|
||||
{hasProblems && onPress ? <Text style={styles.chevron}>revisar ›</Text> : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<TouchableOpacity activeOpacity={0.8} onPress={onPress}>
|
||||
{content}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -34,5 +47,6 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
text: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||
text: { color: '#fff', fontSize: 13, fontWeight: '600', flex: 1 },
|
||||
chevron: { color: '#fff', fontSize: 12, fontWeight: '700', textDecorationLine: 'underline' },
|
||||
});
|
||||
|
||||
@@ -12,6 +12,16 @@ const DEV_HOST = 'http://10.0.2.2/construprogress/public';
|
||||
|
||||
export const BASE_URL = `${DEV_HOST}/api/v1`;
|
||||
|
||||
/** Raíz del host (para resolver URLs relativas de media, p.ej. `/storage/...`). */
|
||||
export const ORIGIN = DEV_HOST;
|
||||
|
||||
/** Convierte una url de media (posiblemente relativa) en absoluta. */
|
||||
export function absoluteUrl(url?: string | null): string | undefined {
|
||||
if (!url) return undefined;
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
return `${ORIGIN}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
|
||||
/** Se envía en cada petición como cabecera X-App-Version (ver protocolo §7). */
|
||||
export const APP_VERSION = '1.0.0';
|
||||
|
||||
|
||||
+8
-1
@@ -18,9 +18,16 @@ async function openAndMigrate(): Promise<SQLite.SQLiteDatabase> {
|
||||
const db = await SQLite.openDatabaseAsync(DB_NAME);
|
||||
await db.execAsync(SCHEMA_SQL);
|
||||
|
||||
// Versionado por PRAGMA user_version (migraciones futuras van aquí).
|
||||
// Versionado por PRAGMA user_version. Migraciones incrementales.
|
||||
const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
|
||||
const current = row?.user_version ?? 0;
|
||||
|
||||
if (current < 2) {
|
||||
// local_id: id temporal de la fila creada offline que produjo esta operación
|
||||
// (para reconciliar el id del servidor tras el PUSH).
|
||||
await db.execAsync('ALTER TABLE outbox ADD COLUMN local_id INTEGER');
|
||||
}
|
||||
|
||||
if (current < SCHEMA_VERSION) {
|
||||
await db.execAsync(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
||||
}
|
||||
|
||||
+74
-5
@@ -20,19 +20,21 @@ export async function enqueueOperation(
|
||||
entity: SyncEntity,
|
||||
op: SyncOp,
|
||||
data: Record<string, unknown>,
|
||||
opts?: { uuid?: string; localId?: number },
|
||||
): Promise<string> {
|
||||
const db = await getDb();
|
||||
const uuid = newUuid();
|
||||
const uuid = opts?.uuid ?? newUuid();
|
||||
const clientUpdatedAt = nowIso();
|
||||
await db.runAsync(
|
||||
`INSERT INTO outbox (uuid, entity, op, data, client_updated_at, status, attempts, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`,
|
||||
`INSERT INTO outbox (uuid, entity, op, data, client_updated_at, status, attempts, created_at, local_id)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
|
||||
uuid,
|
||||
entity,
|
||||
op,
|
||||
JSON.stringify(data),
|
||||
clientUpdatedAt,
|
||||
clientUpdatedAt,
|
||||
opts?.localId ?? null,
|
||||
);
|
||||
return uuid;
|
||||
}
|
||||
@@ -49,10 +51,17 @@ interface RawOutbox {
|
||||
server_payload: string | null;
|
||||
attempts: number;
|
||||
created_at: string;
|
||||
local_id: number | null;
|
||||
}
|
||||
|
||||
/** Operación pendiente enriquecida con su id local temporal (si la creó offline). */
|
||||
export interface PendingOp extends Operation {
|
||||
/** id temporal (negativo) de la fila local que produjo esta operación, o null. */
|
||||
localId: number | null;
|
||||
}
|
||||
|
||||
/** Operaciones pendientes de enviar, en orden de creación. */
|
||||
export async function getPendingOperations(limit = 100): Promise<Operation[]> {
|
||||
export async function getPendingOutbox(limit = 200): Promise<PendingOp[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.getAllAsync<RawOutbox>(
|
||||
`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,
|
||||
client_updated_at: r.client_updated_at,
|
||||
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. */
|
||||
export async function applyOperationResult(result: OperationResult): Promise<void> {
|
||||
const db = await getDb();
|
||||
@@ -97,6 +116,41 @@ export async function purgeSentOperations(): Promise<void> {
|
||||
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 {
|
||||
pending: number;
|
||||
conflict: number;
|
||||
@@ -173,12 +227,27 @@ export interface MediaOutboxRow {
|
||||
|
||||
export async function getPendingMedia(limit = 50): Promise<MediaOutboxRow[]> {
|
||||
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>(
|
||||
`SELECT * FROM media_outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`,
|
||||
`SELECT * FROM media_outbox WHERE status = 'pending' AND parent_id > 0 ORDER BY created_at ASC LIMIT ?`,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/** Media local (pendiente o con error) de un padre, para previsualizar en la UI. */
|
||||
export async function getMediaOutboxFor(
|
||||
parentEntity: string,
|
||||
parentId: number,
|
||||
): Promise<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> {
|
||||
const db = await getDb();
|
||||
await db.runAsync(
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
Bundle,
|
||||
DeletedTombstones,
|
||||
Feature,
|
||||
Inspection,
|
||||
Issue,
|
||||
IssueComment,
|
||||
IssueTask,
|
||||
Layer,
|
||||
Media,
|
||||
Phase,
|
||||
Project,
|
||||
Template,
|
||||
@@ -298,6 +300,117 @@ export async function getIssues(projectId: number): Promise<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> {
|
||||
const db = await getDb();
|
||||
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();
|
||||
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).
|
||||
*/
|
||||
|
||||
export const SCHEMA_VERSION = 1;
|
||||
export const SCHEMA_VERSION = 2;
|
||||
|
||||
export const SCHEMA_SQL = `
|
||||
PRAGMA journal_mode = WAL;
|
||||
|
||||
@@ -6,6 +6,11 @@ import { useSession } from '../auth/session';
|
||||
import { LoginScreen } from '../screens/LoginScreen';
|
||||
import { ProjectDetailScreen } from '../screens/ProjectDetailScreen';
|
||||
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';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
@@ -38,6 +43,31 @@ export function RootNavigator() {
|
||||
component={ProjectDetailScreen}
|
||||
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>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export type RootStackParamList = {
|
||||
Projects: undefined;
|
||||
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 React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Issue, Phase } from '../api/types';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SyncStatusBar } from '../components/SyncStatusBar';
|
||||
import { getOutboxCounts, OutboxCounts } from '../db/outbox';
|
||||
import { getCursor, getIssues, getPhases } from '../db/repositories';
|
||||
import { isOnline } from '../net/connectivity';
|
||||
import { runSync } from '../sync/engine';
|
||||
import { useAutoSync } from '../sync/useAutoSync';
|
||||
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'>;
|
||||
|
||||
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 [phases, setPhases] = useState<Phase[]>([]);
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [tab, setTab] = useState<Tab>('Fases');
|
||||
const [counts, setCounts] = useState<OutboxCounts>(EMPTY);
|
||||
const [cursor, setCursorState] = useState<string | null>(null);
|
||||
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 [ph, iss, c, cur] = await Promise.all([
|
||||
getPhases(projectId),
|
||||
getIssues(projectId),
|
||||
getOutboxCounts(),
|
||||
getCursor(projectId),
|
||||
]);
|
||||
setPhases(ph);
|
||||
setIssues(iss);
|
||||
setCounts(c);
|
||||
setCursorState(cur);
|
||||
}, [projectId]);
|
||||
const refreshCounts = useCallback(() => {
|
||||
void getOutboxCounts().then(setCounts);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
useFocusEffect(refreshCounts);
|
||||
|
||||
/** 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 () => {
|
||||
if (!(await isOnline())) {
|
||||
Alert.alert('Sin conexión', 'Conéctate para sincronizar.');
|
||||
return;
|
||||
}
|
||||
setSyncing(true);
|
||||
try {
|
||||
const report = await runSync(projectId);
|
||||
await refresh();
|
||||
const report = await doSync();
|
||||
Alert.alert(
|
||||
'Sincronización completada',
|
||||
`Enviadas: ${report.applied}/${report.pushed}\n` +
|
||||
@@ -55,71 +66,53 @@ export function ProjectDetailScreen({ route }: Props) {
|
||||
);
|
||||
} catch (e) {
|
||||
Alert.alert('Error de sincronización', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [projectId, refresh]);
|
||||
}, [doSync]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SyncStatusBar counts={counts} syncing={syncing} />
|
||||
<SyncStatusBar
|
||||
counts={counts}
|
||||
syncing={syncing}
|
||||
onPress={() => navigation.navigate('Outbox')}
|
||||
/>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
<Text style={styles.section}>Fases ({phases.length})</Text>
|
||||
{phases.map((p) => (
|
||||
<View key={p.id} style={styles.card}>
|
||||
<Text style={styles.cardTitle}>{p.name}</Text>
|
||||
<Text style={styles.cardMeta}>{Math.round(p.progress_percent ?? 0)}%</Text>
|
||||
</View>
|
||||
<View style={styles.tabs}>
|
||||
{TABS.map((t) => (
|
||||
<TouchableOpacity
|
||||
key={t}
|
||||
style={[styles.tab, tab === t && styles.tabActive]}
|
||||
onPress={() => setTab(t)}
|
||||
>
|
||||
<Text style={[styles.tabText, tab === t && styles.tabTextActive]}>{t}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={styles.section}>Incidencias ({issues.length})</Text>
|
||||
{issues.map((i) => (
|
||||
<View key={i.id} style={styles.card}>
|
||||
<Text style={styles.cardTitle}>{i.title}</Text>
|
||||
<Text style={styles.cardMeta}>
|
||||
{i.priority ?? '—'} · {i.status ?? '—'}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.content} key={`${tab}-${nonce}`}>
|
||||
{tab === 'Fases' && <PhasesSection projectId={projectId} />}
|
||||
{tab === 'Features' && <FeaturesSection projectId={projectId} />}
|
||||
{tab === 'Incidencias' && <IssuesSection projectId={projectId} />}
|
||||
</View>
|
||||
|
||||
<Text style={styles.cursor}>
|
||||
Último sync: {cursor ? new Date(cursor).toLocaleString() : 'nunca'}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.syncBtn, syncing && styles.syncBtnDisabled]}
|
||||
onPress={onSync}
|
||||
disabled={syncing}
|
||||
>
|
||||
<Text style={styles.syncBtnText}>{syncing ? 'Sincronizando…' : 'Sincronizar'}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.footer}>
|
||||
<PrimaryButton
|
||||
title={syncing ? 'Sincronizando…' : 'Sincronizar'}
|
||||
onPress={() => void onSync()}
|
||||
loading={syncing}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
body: { padding: 16, gap: 8 },
|
||||
section: { fontSize: 16, fontWeight: '700', marginTop: 12 },
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#f4f4f4',
|
||||
borderRadius: 8,
|
||||
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' },
|
||||
tabs: { flexDirection: 'row', borderBottomWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
|
||||
tab: { flex: 1, paddingVertical: 12, alignItems: 'center' },
|
||||
tabActive: { borderBottomWidth: 2, borderColor: COLORS.primary },
|
||||
tabText: { fontSize: 14, color: COLORS.muted },
|
||||
tabTextActive: { color: COLORS.primary, fontWeight: '700' },
|
||||
content: { flex: 1 },
|
||||
footer: { padding: 12, borderTopWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import * as api from '../api/endpoints';
|
||||
import { Project } from '../api/types';
|
||||
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 { initialPull, runSync } from '../sync/engine';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
@@ -52,6 +52,7 @@ export function ProjectsScreen({ navigation }: Props) {
|
||||
async (p: Project) => {
|
||||
setOpening(p.id);
|
||||
try {
|
||||
await setActiveProjectId(p.id);
|
||||
const online = await isOnline();
|
||||
if (online) {
|
||||
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.
|
||||
* 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.
|
||||
*
|
||||
* 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 { Operation, OperationResult, SyncEntity } from '../api/types';
|
||||
import { applyBundle, applyServerValue, getCursor } from '../db/repositories';
|
||||
import {
|
||||
applyBundle,
|
||||
applyServerValue,
|
||||
getCursor,
|
||||
reconcileCreate,
|
||||
} from '../db/repositories';
|
||||
import {
|
||||
applyOperationResult,
|
||||
getPendingMedia,
|
||||
getPendingOperations,
|
||||
getPendingOutbox,
|
||||
markMediaError,
|
||||
markMediaSent,
|
||||
PendingOp,
|
||||
updateOutboxData,
|
||||
} from '../db/outbox';
|
||||
|
||||
const SYNC_BATCH = 100;
|
||||
@@ -32,6 +44,9 @@ const ENTITY_TABLE: Partial<Record<SyncEntity, string>> = {
|
||||
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 {
|
||||
pushed: number;
|
||||
applied: number;
|
||||
@@ -42,54 +57,123 @@ export interface SyncReport {
|
||||
pulled: boolean;
|
||||
}
|
||||
|
||||
/** Vacía el outbox de operaciones contra /sync. */
|
||||
async function pushOperations(report: SyncReport): Promise<void> {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const ops = await getPendingOperations(SYNC_BATCH);
|
||||
if (ops.length === 0) break;
|
||||
function negativeRefs(data: Record<string, unknown>): number[] {
|
||||
const out: number[] = [];
|
||||
for (const f of FK_FIELDS) {
|
||||
const v = data[f];
|
||||
if (typeof v === 'number' && v < 0) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const { results } = await syncApi(ops);
|
||||
report.pushed += ops.length;
|
||||
|
||||
const byUuid = new Map<string, Operation>(ops.map((o) => [o.uuid, o]));
|
||||
for (const result of results) {
|
||||
await processResult(result, byUuid.get(result.uuid), report);
|
||||
/** Reescribe en `data` los ids temporales ya resueltos. Devuelve si cambió algo. */
|
||||
function rewriteRefs(data: Record<string, unknown>, idMap: Map<number, number>): boolean {
|
||||
let changed = false;
|
||||
for (const f of FK_FIELDS) {
|
||||
const v = data[f];
|
||||
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
|
||||
// bucear infinitamente con conflictos/errores.
|
||||
if (results.every((r) => r.status !== 'applied' && r.status !== 'duplicate')) {
|
||||
break;
|
||||
function toOperation(op: PendingOp): Operation {
|
||||
return {
|
||||
entity: op.entity,
|
||||
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(
|
||||
result: OperationResult,
|
||||
op: Operation | undefined,
|
||||
op: PendingOp | undefined,
|
||||
report: SyncReport,
|
||||
): Promise<void> {
|
||||
idMap: Map<number, number>,
|
||||
): Promise<boolean> {
|
||||
await applyOperationResult(result);
|
||||
|
||||
if (result.status === 'applied' || result.status === 'duplicate') {
|
||||
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;
|
||||
// Volcamos el valor del servidor a la BD local (last-write-wins servidor).
|
||||
if (op && result.server) {
|
||||
const table = ENTITY_TABLE[op.entity];
|
||||
const id = (op.data as { id?: number }).id;
|
||||
if (table && id != null) {
|
||||
if (table && id != null && id > 0) {
|
||||
await applyServerValue(table, id, result.server);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
report.errors += 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
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> {
|
||||
const pending = await getPendingMedia();
|
||||
for (const m of pending) {
|
||||
|
||||
+55
-8
@@ -8,6 +8,8 @@
|
||||
*/
|
||||
import { getDb } from '../db/database';
|
||||
import { enqueueOperation } from '../db/outbox';
|
||||
import { insertLocalCreate } from '../db/repositories';
|
||||
import { newUuid, nextTempId, nowIso } from './uuid';
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
@@ -60,7 +62,7 @@ export async function updateFeature(input: {
|
||||
|
||||
// ----- inspection.create (append-only) -----
|
||||
|
||||
export function createInspection(input: {
|
||||
export async function createInspection(input: {
|
||||
feature_id: number;
|
||||
template_id?: number;
|
||||
data?: Record<string, unknown>;
|
||||
@@ -68,12 +70,25 @@ export function createInspection(input: {
|
||||
result?: string;
|
||||
notes?: 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 -----
|
||||
|
||||
export function createIssue(input: {
|
||||
export async function createIssue(input: {
|
||||
project_id: number;
|
||||
feature_id?: number;
|
||||
title: string;
|
||||
@@ -82,7 +97,20 @@ export function createIssue(input: {
|
||||
status?: IssueStatus;
|
||||
type?: IssueType;
|
||||
}): 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: {
|
||||
@@ -125,14 +153,25 @@ export async function updateIssue(input: {
|
||||
|
||||
// ----- issue tasks -----
|
||||
|
||||
export function createIssueTask(input: {
|
||||
export async function createIssueTask(input: {
|
||||
issue_id: number;
|
||||
title: string;
|
||||
assigned_to?: number;
|
||||
due_date?: string;
|
||||
is_done?: boolean;
|
||||
}): 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: {
|
||||
@@ -155,9 +194,17 @@ export async function updateIssueTask(input: {
|
||||
|
||||
// ----- issue comments (append-only) -----
|
||||
|
||||
export function createIssueComment(input: {
|
||||
export async function createIssueComment(input: {
|
||||
issue_id: number;
|
||||
body: 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 {
|
||||
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