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:
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
Reference in New Issue
Block a user