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

209 lines
6.8 KiB
TypeScript
Raw Normal View History

/**
* 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`.
*/
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
2026-07-07 17:39:47 +02:00
import { Alert, 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[];
2026-07-07 17:39:47 +02:00
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}`);
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);
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;
}
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 () => {
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;
}
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);
}
2026-07-07 17:39:47 +02:00
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [featureId, templateId, values, result, notes, navigation, template]);
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>
);
};
return (
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.subtitle}>{featureName}</Text>
<Text style={styles.tplName}>{template?.name ?? 'Inspección libre'}</Text>
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>
))}
<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 },
2026-07-07 17:39:47 +02:00
help: { fontSize: 12, color: COLORS.muted, marginTop: -6, marginBottom: 8 },
});