feat(ui): quitar pestaña Fases + selector de plantilla al inspeccionar

- ProjectDetail: pestañas reducidas a Features | Incidencias (Features por
  defecto); PhasesSection eliminada.
- InspectionForm en dos pasos: 1) selector de plantillas asignadas al
  proyecto (la de la feature marcada como sugerida ★, opción de inspección
  libre), 2) formulario dinámico. Enlace "Cambiar plantilla" para volver.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 10:50:20 +02:00
co-authored by Claude Sonnet 4.6
parent 9c4c3b54ae
commit 190b4f6f34
3 changed files with 100 additions and 104 deletions
+98 -7
View File
@@ -8,9 +8,9 @@
*/
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
import { Alert, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { Template } from '../api/types';
import { getTemplate } from '../db/repositories';
import { getTemplates } from '../db/repositories';
import { createInspection } from '../sync/mutations';
import { RootStackParamList } from '../navigation/types';
import {
@@ -75,7 +75,12 @@ function groupFields(fields: NormField[]): { group: string; items: NormField[] }
const RESULTS = ['pass', 'fail', 'na'] as const;
export function InspectionFormScreen({ route, navigation }: Props) {
const { featureId, featureName, templateId } = route.params;
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>();
@@ -83,8 +88,27 @@ export function InspectionFormScreen({ route, navigation }: Props) {
const [saving, setSaving] = useState(false);
useEffect(() => {
if (templateId != null) void getTemplate(templateId).then(setTemplate);
}, [templateId]);
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);
}, []);
const backToPicker = useCallback(() => {
setChosen(false);
setTemplate(null);
setValues({});
}, []);
const fields: NormField[] = (template?.fields ?? []).map(normalizeField);
@@ -111,7 +135,7 @@ export function InspectionFormScreen({ route, navigation }: Props) {
try {
await createInspection({
feature_id: featureId,
template_id: templateId,
template_id: template?.id ?? undefined,
data: values,
result,
notes: notes.trim() || undefined,
@@ -122,7 +146,7 @@ export function InspectionFormScreen({ route, navigation }: Props) {
setSaving(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [featureId, templateId, values, result, notes, navigation, template]);
}, [featureId, values, result, notes, navigation, template]);
const renderField = (f: NormField) => {
const label = f.required ? `${f.label} *` : f.label;
@@ -162,10 +186,61 @@ export function InspectionFormScreen({ route, navigation }: Props) {
);
};
// ── 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>
))}
<View style={{ height: 8 }} />
<PrimaryButton
title="Inspección libre (sin plantilla)"
variant="ghost"
onPress={() => pickTemplate(null)}
/>
<View style={{ height: 8 }} />
<PrimaryButton title="Cancelar" variant="ghost" onPress={() => navigation.goBack()} />
</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}`}>
@@ -205,4 +280,20 @@ const styles = StyleSheet.create({
},
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 },
});
+2 -4
View File
@@ -9,19 +9,18 @@ import { runSync } from '../sync/engine';
import { useAutoSync } from '../sync/useAutoSync';
import { RootStackParamList } from '../navigation/types';
import { COLORS, PrimaryButton } from '../ui/components';
import { PhasesSection } from './sections/PhasesSection';
import { FeaturesSection } from './sections/FeaturesSection';
import { IssuesSection } from './sections/IssuesSection';
type Props = NativeStackScreenProps<RootStackParamList, 'ProjectDetail'>;
const EMPTY: OutboxCounts = { pending: 0, conflict: 0, error: 0, mediaPending: 0 };
const TABS = ['Fases', 'Features', 'Incidencias'] as const;
const TABS = ['Features', 'Incidencias'] as const;
type Tab = (typeof TABS)[number];
export function ProjectDetailScreen({ route, navigation }: Props) {
const { projectId } = route.params;
const [tab, setTab] = useState<Tab>('Fases');
const [tab, setTab] = useState<Tab>('Features');
const [counts, setCounts] = useState<OutboxCounts>(EMPTY);
const [syncing, setSyncing] = useState(false);
// Se incrementa tras cada sync para forzar el recargado de la sección visible.
@@ -90,7 +89,6 @@ export function ProjectDetailScreen({ route, navigation }: Props) {
</View>
<View style={styles.content} key={`${tab}-${nonce}`}>
{tab === 'Fases' && <PhasesSection projectId={projectId} />}
{tab === 'Features' && <FeaturesSection projectId={projectId} />}
{tab === 'Incidencias' && <IssuesSection projectId={projectId} />}
</View>
-93
View File
@@ -1,93 +0,0 @@
/**
* Sección Fases: lista con barra de progreso y registro rápido de avance
* (operación append-only progress_update).
*/
import { useFocusEffect } from '@react-navigation/native';
import React, { useCallback, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Phase } from '../../api/types';
import { hasPermission, useSession } from '../../auth/session';
import { getPhases } from '../../db/repositories';
import { recordProgressUpdate } from '../../sync/mutations';
import { Card, COLORS, EmptyState } from '../../ui/components';
import { useLayout } from '../../ui/responsive';
const QUICK = [25, 50, 75, 100];
export function PhasesSection({ projectId }: { projectId: number }) {
const { user } = useSession();
const canProgress = hasPermission(user, 'update progress');
const { columns, gutter } = useLayout();
const [phases, setPhases] = useState<Phase[]>([]);
const load = useCallback(() => {
void getPhases(projectId).then(setPhases);
}, [projectId]);
useFocusEffect(load);
const onQuick = useCallback(
async (phase: Phase, progress: number) => {
await recordProgressUpdate({ phase_id: phase.id, progress });
// Reflejo optimista en la barra (el valor real llega en el próximo PULL).
setPhases((prev) =>
prev.map((p) => (p.id === phase.id ? { ...p, progress_percent: progress } : p)),
);
},
[],
);
if (phases.length === 0) return <EmptyState text="Sin fases." />;
return (
<ScrollView contentContainerStyle={[styles.body, { padding: gutter }]}>
<View style={[styles.grid, columns > 1 && { gap: gutter }]}>
{phases.map((p) => {
const pct = Math.round(p.progress_percent ?? 0);
return (
<Card key={p.id} style={[styles.card, columns > 1 && { width: `${100 / columns - 2}%` }]}>
<View style={styles.headerRow}>
{p.color ? <View style={[styles.dot, { backgroundColor: p.color }]} /> : null}
<Text style={styles.name}>{p.name}</Text>
<Text style={styles.pct}>{pct}%</Text>
</View>
<View style={styles.track}>
<View style={[styles.fill, { width: `${pct}%` }]} />
</View>
{canProgress && (
<View style={styles.quickRow}>
{QUICK.map((q) => (
<TouchableOpacity key={q} style={styles.quickBtn} onPress={() => void onQuick(p, q)}>
<Text style={styles.quickText}>{q}%</Text>
</TouchableOpacity>
))}
</View>
)}
</Card>
);
})}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
body: { gap: 10 },
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
card: { width: '100%', gap: 8 },
headerRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
dot: { width: 12, height: 12, borderRadius: 6 },
name: { fontSize: 15, fontWeight: '600', flex: 1 },
pct: { fontSize: 13, color: COLORS.muted },
track: { height: 8, borderRadius: 4, backgroundColor: '#e3e3e3', overflow: 'hidden' },
fill: { height: '100%', backgroundColor: COLORS.primary },
quickRow: { flexDirection: 'row', gap: 6 },
quickBtn: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
borderWidth: 1,
borderColor: COLORS.border,
},
quickText: { fontSize: 12, color: COLORS.muted, fontWeight: '600' },
});