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:
2026-07-07 17:39:47 +02:00
co-authored by Claude Sonnet 4.6
parent c092052a1c
commit fdf85e7cc9
9 changed files with 307 additions and 82 deletions
+21 -3
View File
@@ -65,9 +65,19 @@ export interface Feature {
progress?: number;
responsible?: string | null;
template_id?: number | null;
feature_type_id?: number | null;
is_active?: boolean | number;
updated_at: string;
}
/** Catálogo global de tipos de feature (v1.1). */
export interface FeatureType {
id: number;
name: string;
color?: string | null;
updated_at?: string;
}
export interface Inspection {
id: number;
feature_id: number;
@@ -122,10 +132,17 @@ export interface IssueComment {
updated_at: string;
}
/**
* Plantilla de inspección. Desde v1.1 son un catálogo GLOBAL asignado a
* proyectos vía pivot: `project_id` es informativo (puede ser null) y ya NO
* determina visibilidad; `phase_id` se eliminó del contrato.
* Cada item de `fields[]` puede incluir `group` (sección), `question`
* (prompt corto) y `help` (ayuda larga), además de name/label/type/required/
* options/min/max/step.
*/
export interface Template {
id: number;
project_id?: number;
phase_id?: number | null;
project_id?: number | null;
name: string;
description?: string | null;
fields?: unknown[];
@@ -172,9 +189,10 @@ export interface Bundle {
phases: Phase[];
layers: Layer[];
features: Feature[];
feature_types?: FeatureType[];
inspections: Inspection[];
issues: Issue[];
issue_tasks: IssueComment[] | IssueTask[];
issue_tasks: IssueTask[];
issue_comments: IssueComment[];
templates: Template[];
media: Media[];
+8
View File
@@ -28,6 +28,13 @@ async function openAndMigrate(): Promise<SQLite.SQLiteDatabase> {
await db.execAsync('ALTER TABLE outbox ADD COLUMN local_id INTEGER');
}
if (current < 3) {
// API v1.1: feature gana feature_type_id + is_active. La tabla
// feature_types la crea SCHEMA_SQL (CREATE TABLE IF NOT EXISTS).
await db.execAsync('ALTER TABLE features ADD COLUMN feature_type_id INTEGER');
await db.execAsync('ALTER TABLE features ADD COLUMN is_active INTEGER DEFAULT 1');
}
if (current < SCHEMA_VERSION) {
await db.execAsync(`PRAGMA user_version = ${SCHEMA_VERSION}`);
}
@@ -43,6 +50,7 @@ export async function wipeDatabase(): Promise<void> {
'phases',
'layers',
'features',
'feature_types',
'inspections',
'issues',
'issue_tasks',
+25 -4
View File
@@ -8,6 +8,7 @@ import {
Bundle,
DeletedTombstones,
Feature,
FeatureType,
Inspection,
Issue,
IssueComment,
@@ -102,6 +103,9 @@ export async function applyBundle(bundle: Bundle): Promise<void> {
for (const f of bundle.features ?? []) {
await upsertById(db, 'features', { ...pickFeature(f), project_id: pid });
}
for (const ft of bundle.feature_types ?? []) {
await upsertById(db, 'feature_types', pickFeatureType(ft));
}
for (const i of bundle.inspections ?? []) {
await upsertById(db, 'inspections', { ...pickInspection(i), project_id: pid });
}
@@ -202,9 +206,18 @@ const pickFeature = (f: Feature) => ({
progress: f.progress ?? null,
responsible: f.responsible ?? null,
template_id: f.template_id ?? null,
feature_type_id: f.feature_type_id ?? null,
is_active: f.is_active == null ? 1 : Number(f.is_active),
updated_at: f.updated_at,
});
const pickFeatureType = (t: FeatureType) => ({
id: t.id,
name: t.name,
color: t.color ?? null,
updated_at: t.updated_at ?? null,
});
const pickInspection = (i: import('../api/types').Inspection) => ({
id: i.id,
feature_id: i.feature_id,
@@ -258,7 +271,6 @@ const pickIssueComment = (c: IssueComment) => ({
const pickTemplate = (t: Template) => ({
id: t.id,
project_id: t.project_id ?? null,
phase_id: t.phase_id ?? null,
name: t.name,
description: t.description ?? null,
fields: json(t.fields),
@@ -380,11 +392,15 @@ export async function getIssueComments(issueId: number): Promise<IssueComment[]>
);
}
export async function getTemplates(projectId: number): Promise<Template[]> {
/**
* Plantillas locales. Desde API v1.1 son un catálogo global asignado por
* pivot: el bundle solo trae las asignadas, así que aquí no se filtra por
* project_id (es un campo informativo que puede ser null).
*/
export async function getTemplates(): Promise<Template[]> {
const db = await getDb();
const rows = await db.getAllAsync<Template & { fields: string | null }>(
'SELECT * FROM templates WHERE project_id = ? OR project_id IS NULL ORDER BY name',
projectId,
'SELECT * FROM templates ORDER BY name',
);
return rows.map((r) => ({
...r,
@@ -392,6 +408,11 @@ export async function getTemplates(projectId: number): Promise<Template[]> {
}));
}
export async function getFeatureTypes(): Promise<FeatureType[]> {
const db = await getDb();
return db.getAllAsync<FeatureType>('SELECT * FROM feature_types ORDER BY name');
}
export async function getTemplate(id: number): Promise<Template | null> {
const db = await getDb();
const row = await db.getFirstAsync<Template & { fields: string | null }>(
+10 -1
View File
@@ -6,7 +6,7 @@
* Versionado simple por `user_version` de SQLite (ver database.ts).
*/
export const SCHEMA_VERSION = 2;
export const SCHEMA_VERSION = 3;
export const SCHEMA_SQL = `
PRAGMA journal_mode = WAL;
@@ -65,6 +65,15 @@ CREATE TABLE IF NOT EXISTS features (
-- columnas locales para conciliar creaciones offline:
local_uuid TEXT, -- uuid de la operación que la creó (si nació offline)
dirty INTEGER DEFAULT 0 -- 1 = hay cambios locales sin confirmar
-- v3 añade por migración: feature_type_id INTEGER, is_active INTEGER
);
-- Catálogo global de tipos de feature (v1.1 de la API).
CREATE TABLE IF NOT EXISTS feature_types (
id INTEGER PRIMARY KEY,
name TEXT,
color TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS inspections (
+92 -40
View File
@@ -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 },
});
+36 -4
View File
@@ -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,
+10
View File
@@ -34,6 +34,8 @@ export async function updateFeature(input: {
status?: string;
progress?: number;
responsible?: string;
is_active?: boolean;
feature_type_id?: number;
}): Promise<string> {
const db = await getDb();
const sets: string[] = [];
@@ -50,6 +52,14 @@ export async function updateFeature(input: {
sets.push('responsible = ?');
params.push(input.responsible);
}
if (input.is_active !== undefined) {
sets.push('is_active = ?');
params.push(input.is_active ? 1 : 0);
}
if (input.feature_type_id !== undefined) {
sets.push('feature_type_id = ?');
params.push(input.feature_type_id);
}
if (sets.length) {
params.push(input.id);
await db.runAsync(