2026-06-18 18:30:44 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Formulario de inspección generado dinámicamente desde los `fields` de una
|
2026-07-07 17:39:47 +02:00
|
|
|
|
* plantilla. El renderer es tolerante: deduce key/label/type de varias formas.
|
|
|
|
|
|
*
|
|
|
|
|
|
* API v1.1: cada campo puede traer `group` (sección de agrupación),
|
|
|
|
|
|
* `question` (prompt corto mostrado como etiqueta principal), `help`
|
|
|
|
|
|
* (texto de ayuda bajo el campo) y `required`.
|
2026-06-18 18:30:44 +02:00
|
|
|
|
*/
|
|
|
|
|
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
|
|
|
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
2026-07-08 10:50:20 +02:00
|
|
|
|
import { Alert, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
2026-06-18 18:30:44 +02:00
|
|
|
|
import { Template } from '../api/types';
|
2026-07-08 10:50:20 +02:00
|
|
|
|
import { getTemplates } from '../db/repositories';
|
2026-06-18 18:30:44 +02:00
|
|
|
|
import { createInspection } from '../sync/mutations';
|
2026-07-28 19:42:11 +02:00
|
|
|
|
import { getDb } from '../db/database';
|
|
|
|
|
|
import { newUuid, nextTempId } from '../sync/uuid';
|
2026-06-18 18:30:44 +02:00
|
|
|
|
import { RootStackParamList } from '../navigation/types';
|
2026-07-28 19:42:11 +02:00
|
|
|
|
import { MediaStrip } from '../ui/MediaStrip';
|
2026-06-18 18:30:44 +02:00
|
|
|
|
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[];
|
2026-07-07 17:39:47 +02:00
|
|
|
|
group: string;
|
|
|
|
|
|
help: string | null;
|
|
|
|
|
|
required: boolean;
|
2026-06-18 18:30:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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}`);
|
2026-07-07 17:39:47 +02:00
|
|
|
|
// `question` (v1.1) tiene prioridad como etiqueta visible.
|
|
|
|
|
|
const label = String(f.question ?? f.label ?? f.name ?? f.key ?? key);
|
2026-06-18 18:30:44 +02:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
: [];
|
2026-07-07 17:39:47 +02:00
|
|
|
|
return {
|
|
|
|
|
|
key,
|
|
|
|
|
|
label,
|
|
|
|
|
|
type: type as NormField['type'],
|
|
|
|
|
|
options,
|
|
|
|
|
|
group: typeof f.group === 'string' ? f.group : '',
|
|
|
|
|
|
help: typeof f.help === 'string' && f.help ? f.help : null,
|
|
|
|
|
|
required: f.required === true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Agrupa los campos por `group` preservando el orden de aparición. */
|
|
|
|
|
|
function groupFields(fields: NormField[]): { group: string; items: NormField[] }[] {
|
|
|
|
|
|
const out: { group: string; items: NormField[] }[] = [];
|
|
|
|
|
|
for (const f of fields) {
|
|
|
|
|
|
const last = out[out.length - 1];
|
|
|
|
|
|
if (last && last.group === f.group) last.items.push(f);
|
|
|
|
|
|
else out.push({ group: f.group, items: [f] });
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
2026-06-18 18:30:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const RESULTS = ['pass', 'fail', 'na'] as const;
|
|
|
|
|
|
|
|
|
|
|
|
export function InspectionFormScreen({ route, navigation }: Props) {
|
2026-07-08 10:50:20 +02:00
|
|
|
|
const { featureId, featureName, templateId: suggestedId } = route.params;
|
|
|
|
|
|
|
|
|
|
|
|
// Paso 1: elegir plantilla (las asignadas al proyecto llegan en el bundle).
|
|
|
|
|
|
// Paso 2: rellenar el formulario. `chosen` = false → aún en el selector.
|
|
|
|
|
|
const [available, setAvailable] = useState<Template[]>([]);
|
|
|
|
|
|
const [chosen, setChosen] = useState(false);
|
2026-06-18 18:30:44 +02:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-07-28 19:42:11 +02:00
|
|
|
|
// We need the tempId and uuid for the inspection to associate photos with it before saving
|
|
|
|
|
|
const [inspectionTempId, setInspectionTempId] = useState<number | null>(null);
|
|
|
|
|
|
const [inspectionUuid, setInspectionUuid] = useState<string | null>(null);
|
|
|
|
|
|
|
2026-06-18 18:30:44 +02:00
|
|
|
|
useEffect(() => {
|
2026-07-08 10:50:20 +02:00
|
|
|
|
void getTemplates().then((all) => {
|
|
|
|
|
|
// La plantilla sugerida (la de la feature) primero.
|
|
|
|
|
|
all.sort((a, b) =>
|
|
|
|
|
|
Number(b.id === suggestedId) - Number(a.id === suggestedId) ||
|
|
|
|
|
|
a.name.localeCompare(b.name),
|
|
|
|
|
|
);
|
|
|
|
|
|
setAvailable(all);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [suggestedId]);
|
|
|
|
|
|
|
|
|
|
|
|
const pickTemplate = useCallback((t: Template | null) => {
|
|
|
|
|
|
setTemplate(t);
|
|
|
|
|
|
setValues({});
|
|
|
|
|
|
setChosen(true);
|
2026-07-28 19:42:11 +02:00
|
|
|
|
// Generate IDs early so photos can be associated
|
|
|
|
|
|
setInspectionTempId(nextTempId());
|
|
|
|
|
|
setInspectionUuid(newUuid());
|
2026-07-08 10:50:20 +02:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const backToPicker = useCallback(() => {
|
|
|
|
|
|
setChosen(false);
|
|
|
|
|
|
setTemplate(null);
|
|
|
|
|
|
setValues({});
|
2026-07-28 19:42:11 +02:00
|
|
|
|
setInspectionTempId(null);
|
|
|
|
|
|
setInspectionUuid(null);
|
2026-07-08 10:50:20 +02:00
|
|
|
|
}, []);
|
2026-06-18 18:30:44 +02:00
|
|
|
|
|
|
|
|
|
|
const fields: NormField[] = (template?.fields ?? []).map(normalizeField);
|
|
|
|
|
|
|
|
|
|
|
|
const setValue = useCallback((key: string, v: unknown) => {
|
|
|
|
|
|
setValues((prev) => ({ ...prev, [key]: v }));
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const onSubmit = useCallback(async () => {
|
2026-07-07 17:39:47 +02:00
|
|
|
|
// Validación de campos obligatorios (los boolean cuentan siempre).
|
|
|
|
|
|
const missing = fields.filter(
|
|
|
|
|
|
(f) =>
|
|
|
|
|
|
f.required &&
|
|
|
|
|
|
f.type !== 'boolean' &&
|
|
|
|
|
|
(values[f.key] == null || String(values[f.key]).trim() === ''),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (missing.length) {
|
|
|
|
|
|
Alert.alert(
|
|
|
|
|
|
'Campos obligatorios',
|
|
|
|
|
|
`Completa: ${missing.map((f) => f.label).join(', ')}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-18 18:30:44 +02:00
|
|
|
|
setSaving(true);
|
|
|
|
|
|
try {
|
2026-07-28 19:42:11 +02:00
|
|
|
|
// Create the inspection using the pre-generated IDs
|
2026-06-18 18:30:44 +02:00
|
|
|
|
await createInspection({
|
|
|
|
|
|
feature_id: featureId,
|
2026-07-08 10:50:20 +02:00
|
|
|
|
template_id: template?.id ?? undefined,
|
2026-06-18 18:30:44 +02:00
|
|
|
|
data: values,
|
|
|
|
|
|
result,
|
|
|
|
|
|
notes: notes.trim() || undefined,
|
|
|
|
|
|
status: 'completed',
|
2026-07-28 19:42:11 +02:00
|
|
|
|
uuid: inspectionUuid ?? undefined,
|
|
|
|
|
|
localId: inspectionTempId ?? undefined,
|
2026-06-18 18:30:44 +02:00
|
|
|
|
});
|
|
|
|
|
|
navigation.goBack();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
2026-07-07 17:39:47 +02:00
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-07-28 19:42:11 +02:00
|
|
|
|
}, [featureId, values, result, notes, navigation, template, inspectionUuid, inspectionTempId]);
|
2026-07-07 17:39:47 +02:00
|
|
|
|
|
|
|
|
|
|
const renderField = (f: NormField) => {
|
|
|
|
|
|
const label = f.required ? `${f.label} *` : f.label;
|
|
|
|
|
|
let control: React.ReactNode;
|
|
|
|
|
|
if (f.type === 'boolean') {
|
|
|
|
|
|
control = (
|
|
|
|
|
|
<View style={styles.switchRow}>
|
|
|
|
|
|
<Text style={styles.switchLabel}>{label}</Text>
|
|
|
|
|
|
<Switch value={!!values[f.key]} onValueChange={(v) => setValue(f.key, v)} />
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
} else if (f.type === 'select' && f.options.length) {
|
|
|
|
|
|
control = (
|
|
|
|
|
|
<ChipSelect
|
|
|
|
|
|
label={label}
|
|
|
|
|
|
value={values[f.key] as string | undefined}
|
|
|
|
|
|
options={f.options}
|
|
|
|
|
|
onChange={(v) => setValue(f.key, v)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
control = (
|
|
|
|
|
|
<Field
|
|
|
|
|
|
label={label}
|
|
|
|
|
|
value={values[f.key] != null ? String(values[f.key]) : ''}
|
|
|
|
|
|
onChangeText={(t) => setValue(f.key, f.type === 'number' ? Number(t) : t)}
|
|
|
|
|
|
keyboardType={f.type === 'number' ? 'numeric' : 'default'}
|
|
|
|
|
|
multiline={f.type === 'textarea'}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View key={f.key}>
|
|
|
|
|
|
{control}
|
|
|
|
|
|
{f.help ? <Text style={styles.help}>{f.help}</Text> : null}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
2026-06-18 18:30:44 +02:00
|
|
|
|
|
2026-07-08 10:50:20 +02:00
|
|
|
|
// ── Paso 1: selector de plantilla ──
|
|
|
|
|
|
if (!chosen) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ScrollView contentContainerStyle={styles.body}>
|
|
|
|
|
|
<Text style={styles.subtitle}>{featureName}</Text>
|
|
|
|
|
|
<Text style={styles.tplName}>Elige una plantilla</Text>
|
|
|
|
|
|
|
|
|
|
|
|
{available.length === 0 && (
|
|
|
|
|
|
<Text style={styles.noTemplates}>
|
|
|
|
|
|
No hay plantillas asignadas a este proyecto. Sincroniza o crea una
|
|
|
|
|
|
inspección libre.
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{available.map((t) => (
|
|
|
|
|
|
<TouchableOpacity key={t.id} style={styles.tplCard} onPress={() => pickTemplate(t)}>
|
|
|
|
|
|
<View style={{ flex: 1 }}>
|
|
|
|
|
|
<Text style={styles.tplCardName}>
|
|
|
|
|
|
{t.name}
|
|
|
|
|
|
{t.id === suggestedId ? ' ★' : ''}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
{t.description ? (
|
|
|
|
|
|
<Text style={styles.tplCardDesc} numberOfLines={2}>
|
|
|
|
|
|
{t.description}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<Text style={styles.tplCardMeta}>
|
|
|
|
|
|
{(t.fields ?? []).length} campos
|
|
|
|
|
|
{t.id === suggestedId ? ' · sugerida para esta feature' : ''}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
<Text style={styles.tplChevron}>›</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ScrollView>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Paso 2: formulario ──
|
2026-06-18 18:30:44 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<ScrollView contentContainerStyle={styles.body}>
|
|
|
|
|
|
<Text style={styles.subtitle}>{featureName}</Text>
|
|
|
|
|
|
<Text style={styles.tplName}>{template?.name ?? 'Inspección libre'}</Text>
|
2026-07-08 10:50:20 +02:00
|
|
|
|
<TouchableOpacity onPress={backToPicker}>
|
|
|
|
|
|
<Text style={styles.changeTpl}>‹ Cambiar plantilla</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
2026-06-18 18:30:44 +02:00
|
|
|
|
|
2026-07-07 17:39:47 +02:00
|
|
|
|
{groupFields(fields).map((section, si) => (
|
|
|
|
|
|
<View key={`g${si}`}>
|
|
|
|
|
|
{section.group ? <SectionTitle>{section.group}</SectionTitle> : null}
|
|
|
|
|
|
{section.items.map(renderField)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
))}
|
2026-06-18 18:30:44 +02:00
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-07-28 19:42:11 +02:00
|
|
|
|
{inspectionTempId && (
|
|
|
|
|
|
<View style={{ marginBottom: 12 }}>
|
|
|
|
|
|
<SectionTitle>Fotos de la inspección</SectionTitle>
|
|
|
|
|
|
<MediaStrip
|
|
|
|
|
|
parentEntity="inspection"
|
|
|
|
|
|
parentId={inspectionTempId}
|
|
|
|
|
|
canUpload={true}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-18 18:30:44 +02:00
|
|
|
|
<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 },
|
2026-07-07 17:39:47 +02:00
|
|
|
|
help: { fontSize: 12, color: COLORS.muted, marginTop: -6, marginBottom: 8 },
|
2026-07-08 10:50:20 +02:00
|
|
|
|
changeTpl: { color: COLORS.primary, fontSize: 13, fontWeight: '600', marginBottom: 12 },
|
|
|
|
|
|
noTemplates: { color: COLORS.muted, fontSize: 14, marginVertical: 16, textAlign: 'center' },
|
|
|
|
|
|
tplCard: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
borderWidth: 1,
|
|
|
|
|
|
borderColor: COLORS.border,
|
|
|
|
|
|
borderRadius: 10,
|
|
|
|
|
|
padding: 14,
|
|
|
|
|
|
marginBottom: 8,
|
|
|
|
|
|
backgroundColor: '#fff',
|
|
|
|
|
|
},
|
|
|
|
|
|
tplCardName: { fontSize: 15, fontWeight: '700' },
|
|
|
|
|
|
tplCardDesc: { fontSize: 13, color: COLORS.muted, marginTop: 2 },
|
|
|
|
|
|
tplCardMeta: { fontSize: 11, color: COLORS.muted, marginTop: 4 },
|
|
|
|
|
|
tplChevron: { fontSize: 24, color: COLORS.muted, marginLeft: 8 },
|
2026-07-28 19:42:11 +02:00
|
|
|
|
});
|