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
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user