Files
avante-movil/src/screens/InspectionFormScreen.tsx
T

316 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Formulario de inspección generado dinámicamente desde los `fields` de una
* 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`.
*/
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { Template } from '../api/types';
import { getTemplates } from '../db/repositories';
import { createInspection } from '../sync/mutations';
import { getDb } from '../db/database';
import { newUuid, nextTempId } from '../sync/uuid';
import { RootStackParamList } from '../navigation/types';
import { MediaStrip } from '../ui/MediaStrip';
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[];
group: string;
help: string | null;
required: boolean;
}
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}`);
// `question` (v1.1) tiene prioridad como etiqueta visible.
const label = String(f.question ?? 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,
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;
}
const RESULTS = ['pass', 'fail', 'na'] as const;
export function InspectionFormScreen({ route, navigation }: Props) {
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);
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);
// 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);
useEffect(() => {
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);
// Generate IDs early so photos can be associated
setInspectionTempId(nextTempId());
setInspectionUuid(newUuid());
}, []);
const backToPicker = useCallback(() => {
setChosen(false);
setTemplate(null);
setValues({});
setInspectionTempId(null);
setInspectionUuid(null);
}, []);
const fields: NormField[] = (template?.fields ?? []).map(normalizeField);
const setValue = useCallback((key: string, v: unknown) => {
setValues((prev) => ({ ...prev, [key]: v }));
}, []);
const onSubmit = useCallback(async () => {
// 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;
}
setSaving(true);
try {
// Create the inspection using the pre-generated IDs
await createInspection({
feature_id: featureId,
template_id: template?.id ?? undefined,
data: values,
result,
notes: notes.trim() || undefined,
status: 'completed',
uuid: inspectionUuid ?? undefined,
localId: inspectionTempId ?? undefined,
});
navigation.goBack();
} finally {
setSaving(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [featureId, values, result, notes, navigation, template, inspectionUuid, inspectionTempId]);
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>
);
};
// ── 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 ──
return (
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.subtitle}>{featureName}</Text>
<Text style={styles.tplName}>{template?.name ?? 'Inspección libre'}</Text>
<TouchableOpacity onPress={backToPicker}>
<Text style={styles.changeTpl}> Cambiar plantilla</Text>
</TouchableOpacity>
{groupFields(fields).map((section, si) => (
<View key={`g${si}`}>
{section.group ? <SectionTitle>{section.group}</SectionTitle> : null}
{section.items.map(renderField)}
</View>
))}
<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>
{inspectionTempId && (
<View style={{ marginBottom: 12 }}>
<SectionTitle>Fotos de la inspección</SectionTitle>
<MediaStrip
parentEntity="inspection"
parentId={inspectionTempId}
canUpload={true}
/>
</View>
)}
<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 },
help: { fontSize: 12, color: COLORS.muted, marginTop: -6, marginBottom: 8 },
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 },
});