feat(api): adaptar cliente al contrato v1.1
- feature_types: nueva tabla local + tipo FeatureType + upsert en applyBundle + getFeatureTypes(); FeatureDetail muestra badge del tipo con su color. - feature: columnas feature_type_id + is_active (migración BD v3); feature.update acepta ambas; toggle "Activa" en el detalle con badge "inactiva" cuando corresponde. - templates: ahora catálogo global asignado por pivot — getTemplates() ya no filtra por project_id (informativo); phase_id eliminado del tipo y del pick. - Formulario de inspección: soporta fields[].group (secciones), question (etiqueta principal), help (texto de ayuda) y required (validación al guardar con aviso de campos faltantes). - Bundle: tipa issue_tasks correctamente como IssueTask[]. - docs/: sincronizados openapi.yaml y MOBILE_APP_BRIEF.md v1.1 del backend. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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 { ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
|
||||
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';
|
||||
@@ -26,12 +29,16 @@ interface NormField {
|
||||
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}`);
|
||||
const label = String(f.label ?? f.name ?? f.key ?? key);
|
||||
// `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';
|
||||
@@ -43,7 +50,26 @@ function normalizeField(raw: unknown, idx: number): NormField {
|
||||
typeof o === 'string' ? o : String((o as Record<string, unknown>)?.value ?? o),
|
||||
)
|
||||
: [];
|
||||
return { key, label, type: type as NormField['type'], options };
|
||||
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;
|
||||
@@ -67,6 +93,20 @@ export function InspectionFormScreen({ route, navigation }: Props) {
|
||||
}, []);
|
||||
|
||||
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 {
|
||||
await createInspection({
|
||||
@@ -81,47 +121,58 @@ export function InspectionFormScreen({ route, navigation }: Props) {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [featureId, templateId, values, result, notes, navigation]);
|
||||
// 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>
|
||||
|
||||
{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'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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 }}>
|
||||
@@ -153,4 +204,5 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 10,
|
||||
},
|
||||
switchLabel: { fontSize: 15, flex: 1 },
|
||||
help: { fontSize: 12, color: COLORS.muted, marginTop: -6, marginBottom: 8 },
|
||||
});
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
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 { ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Feature, FeatureType, Inspection } from '../../api/types';
|
||||
import { hasPermission, useSession } from '../../auth/session';
|
||||
import { getFeature, getInspectionsByFeature } from '../../db/repositories';
|
||||
import { getFeature, getFeatureTypes, getInspectionsByFeature } from '../../db/repositories';
|
||||
import { updateFeature } from '../../sync/mutations';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import {
|
||||
@@ -35,14 +35,17 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
|
||||
|
||||
const [feature, setFeature] = useState<Feature | null>(null);
|
||||
const [inspections, setInspections] = useState<Inspection[]>([]);
|
||||
const [featureTypes, setFeatureTypes] = useState<FeatureType[]>([]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [f, ins] = await Promise.all([
|
||||
const [f, ins, types] = await Promise.all([
|
||||
getFeature(featureId),
|
||||
getInspectionsByFeature(featureId),
|
||||
getFeatureTypes(),
|
||||
]);
|
||||
setFeature(f);
|
||||
setInspections(ins);
|
||||
setFeatureTypes(types);
|
||||
}, [featureId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,6 +68,14 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
|
||||
[featureId, refresh],
|
||||
);
|
||||
|
||||
const onToggleActive = useCallback(
|
||||
async (active: boolean) => {
|
||||
await updateFeature({ id: featureId, is_active: active });
|
||||
await refresh();
|
||||
},
|
||||
[featureId, refresh],
|
||||
);
|
||||
|
||||
if (!feature) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
@@ -73,12 +84,19 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
const featureType = featureTypes.find((t) => t.id === feature.feature_type_id);
|
||||
const isActive = feature.is_active == null || Number(feature.is_active) !== 0;
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
<Text style={styles.title}>{feature.name}</Text>
|
||||
<View style={styles.badges}>
|
||||
{featureType && (
|
||||
<Badge label={featureType.name} color={featureType.color ?? COLORS.muted} />
|
||||
)}
|
||||
{feature.status && <Badge label={feature.status} color={COLORS.primary} />}
|
||||
<Badge label={`${Math.round(feature.progress ?? 0)}%`} color={COLORS.muted} />
|
||||
{!isActive && <Badge label="inactiva" color={COLORS.danger} />}
|
||||
</View>
|
||||
|
||||
<MediaStrip
|
||||
@@ -110,6 +128,14 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.activeRow}>
|
||||
<Text style={styles.fieldLabel}>Activa</Text>
|
||||
<Switch
|
||||
value={isActive}
|
||||
onValueChange={(v) => void onToggleActive(v)}
|
||||
trackColor={{ true: COLORS.primary }}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -149,6 +175,12 @@ const styles = StyleSheet.create({
|
||||
editCard: { marginTop: 12 },
|
||||
fieldLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 6, fontWeight: '600' },
|
||||
progressRow: { flexDirection: 'row', gap: 6, flexWrap: 'wrap' },
|
||||
activeRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
pBtn: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
|
||||
Reference in New Issue
Block a user