feat(android): app de campo móvil+tablet (UI, mapa, fotos, auto-sync, reconciliación)

Fase 0: config Android (package, permisos, orientación), eas.json (APK), deps
  nativas (react-native-maps, image-picker, location), app.config.js para la key
  de Google Maps por secreto EAS.
Fase 1: capa responsive (useLayout) + componente MasterDetail (dos paneles en
  tablet, navegación en móvil).
Fase 2: pantallas funcionales — detalle de proyecto con secciones Fases/Features/
  Incidencias, edición de progreso/estado, formulario de inspección dinámico
  desde plantilla, incidencias maestro-detalle (checklist + comentarios), alta de
  incidencia; gating por permisos Spatie.
Fase 3: fotos (cámara/galería) → cola de media, con miniaturas pendientes/sync.
Fase 4: mapa de features (Google Maps) con geometría GeoJSON y selección.
Fase 5: auto-sync (foreground/reconexión/intervalo, con candado) + reconciliación
  de creaciones offline (id temporal negativo → server_id, remapeo de FKs hijas).
Fase 6: revisión de conflictos/errores del outbox, indicadores y APK preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
javier
2026-06-18 18:30:44 +02:00
co-authored by Claude Opus 4.8
parent 4e9c7d059f
commit 9bcc51e3b2
36 changed files with 2532 additions and 131 deletions
+10
View File
@@ -0,0 +1,10 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React from 'react';
import { RootStackParamList } from '../navigation/types';
import { FeatureDetailContent } from './detail/FeatureDetailContent';
type Props = NativeStackScreenProps<RootStackParamList, 'FeatureDetail'>;
export function FeatureDetailScreen({ route }: Props) {
return <FeatureDetailContent featureId={route.params.featureId} />;
}
+156
View File
@@ -0,0 +1,156 @@
/**
* 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.
*/
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import { 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[];
}
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);
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),
)
: [];
return { key, label, type: type as NormField['type'], options };
}
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 () => {
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);
}
}, [featureId, templateId, values, result, notes, navigation]);
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'}
/>
);
})}
<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 },
});
+69
View File
@@ -0,0 +1,69 @@
/**
* Alta de incidencia (modal). Crea en local + encola issue.create.
*/
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useState } from 'react';
import { ScrollView, StyleSheet } from 'react-native';
import { IssuePriority, IssueType } from '../api/types';
import { createIssue } from '../sync/mutations';
import { RootStackParamList } from '../navigation/types';
import { ChipSelect, Field, PrimaryButton } from '../ui/components';
type Props = NativeStackScreenProps<RootStackParamList, 'IssueCreate'>;
const PRIORITIES: readonly IssuePriority[] = ['low', 'medium', 'high', 'critical'];
const TYPES: readonly IssueType[] = ['defect', 'safety', 'quality', 'documentation', 'other'];
export function IssueCreateScreen({ route, navigation }: Props) {
const { projectId, featureId } = route.params;
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState<IssuePriority>('medium');
const [type, setType] = useState<IssueType>('defect');
const [saving, setSaving] = useState(false);
const onSubmit = useCallback(async () => {
if (!title.trim()) return;
setSaving(true);
try {
await createIssue({
project_id: projectId,
feature_id: featureId,
title: title.trim(),
description: description.trim() || undefined,
priority,
type,
status: 'open',
});
navigation.goBack();
} finally {
setSaving(false);
}
}, [title, description, priority, type, projectId, featureId, navigation]);
return (
<ScrollView contentContainerStyle={styles.body}>
<Field label="Título" value={title} onChangeText={setTitle} placeholder="Resumen de la incidencia" />
<Field
label="Descripción"
value={description}
onChangeText={setDescription}
placeholder="Detalle…"
multiline
/>
<ChipSelect label="Prioridad" value={priority} options={PRIORITIES} onChange={setPriority} />
<ChipSelect label="Tipo" value={type} options={TYPES} onChange={setType} />
<PrimaryButton
title="Crear incidencia"
onPress={() => void onSubmit()}
loading={saving}
disabled={!title.trim()}
/>
<PrimaryButton title="Cancelar" variant="ghost" onPress={() => navigation.goBack()} />
</ScrollView>
);
}
const styles = StyleSheet.create({
body: { padding: 16, gap: 4 },
});
+10
View File
@@ -0,0 +1,10 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React from 'react';
import { RootStackParamList } from '../navigation/types';
import { IssueDetailContent } from './detail/IssueDetailContent';
type Props = NativeStackScreenProps<RootStackParamList, 'IssueDetail'>;
export function IssueDetailScreen({ route }: Props) {
return <IssueDetailContent issueId={route.params.issueId} />;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Revisión del outbox: operaciones en conflicto o con error. Permite reintentar
* (volver a encolar) o descartar. Los conflictos muestran el valor del servidor.
*/
import { useFocusEffect } from '@react-navigation/native';
import React, { useCallback, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { discardOp, getProblemOps, ProblemOp, retryOp } from '../db/outbox';
import { Badge, Card, COLORS, EmptyState, PrimaryButton } from '../ui/components';
export function OutboxScreen() {
const [ops, setOps] = useState<ProblemOp[]>([]);
const load = useCallback(() => {
void getProblemOps().then(setOps);
}, []);
useFocusEffect(load);
const onRetry = useCallback(
async (uuid: string) => {
await retryOp(uuid);
load();
},
[load],
);
const onDiscard = useCallback(
async (uuid: string) => {
await discardOp(uuid);
load();
},
[load],
);
return (
<ScrollView contentContainerStyle={styles.body}>
{ops.length === 0 && <EmptyState text="Nada pendiente de revisión." />}
{ops.map((o) => (
<Card key={o.uuid} style={styles.card}>
<View style={styles.headerRow}>
<Text style={styles.entity}>
{o.entity}.{o.op}
</Text>
<Badge
label={o.status}
color={o.status === 'conflict' ? COLORS.warn : COLORS.danger}
/>
</View>
{o.error ? <Text style={styles.error}>{o.error}</Text> : null}
{o.server_payload ? (
<Text style={styles.mono} numberOfLines={6}>
Servidor: {o.server_payload}
</Text>
) : null}
<Text style={styles.mono} numberOfLines={6}>
Local: {o.data}
</Text>
<View style={styles.actions}>
<View style={{ flex: 1 }}>
<PrimaryButton title="Reintentar" variant="ghost" onPress={() => void onRetry(o.uuid)} />
</View>
<View style={{ flex: 1 }}>
<PrimaryButton title="Descartar" variant="danger" onPress={() => void onDiscard(o.uuid)} />
</View>
</View>
</Card>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
body: { padding: 16, gap: 12 },
card: { gap: 8 },
headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
entity: { fontSize: 15, fontWeight: '700' },
error: { color: COLORS.danger, fontSize: 13 },
mono: { fontSize: 12, color: COLORS.muted, fontFamily: 'monospace' },
actions: { flexDirection: 'row', gap: 8, marginTop: 4 },
});
+72 -79
View File
@@ -1,52 +1,63 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Issue, Phase } from '../api/types';
import { useFocusEffect } from '@react-navigation/native';
import React, { useCallback, useState } from 'react';
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SyncStatusBar } from '../components/SyncStatusBar';
import { getOutboxCounts, OutboxCounts } from '../db/outbox';
import { getCursor, getIssues, getPhases } from '../db/repositories';
import { isOnline } from '../net/connectivity';
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;
type Tab = (typeof TABS)[number];
export function ProjectDetailScreen({ route }: Props) {
export function ProjectDetailScreen({ route, navigation }: Props) {
const { projectId } = route.params;
const [phases, setPhases] = useState<Phase[]>([]);
const [issues, setIssues] = useState<Issue[]>([]);
const [tab, setTab] = useState<Tab>('Fases');
const [counts, setCounts] = useState<OutboxCounts>(EMPTY);
const [cursor, setCursorState] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
// Se incrementa tras cada sync para forzar el recargado de la sección visible.
const [nonce, setNonce] = useState(0);
const refresh = useCallback(async () => {
const [ph, iss, c, cur] = await Promise.all([
getPhases(projectId),
getIssues(projectId),
getOutboxCounts(),
getCursor(projectId),
]);
setPhases(ph);
setIssues(iss);
setCounts(c);
setCursorState(cur);
}, [projectId]);
const refreshCounts = useCallback(() => {
void getOutboxCounts().then(setCounts);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useFocusEffect(refreshCounts);
/** Ciclo de sync; refresca contadores y la sección visible. */
const doSync = useCallback(async () => {
setSyncing(true);
try {
return await runSync(projectId);
} finally {
refreshCounts();
setNonce((n) => n + 1);
setSyncing(false);
}
}, [projectId, refreshCounts]);
// Auto-sync (silencioso) al recuperar red / volver a primer plano / por intervalo.
useAutoSync(true, async () => {
await doSync();
});
// Sync manual (con resumen).
const onSync = useCallback(async () => {
if (!(await isOnline())) {
Alert.alert('Sin conexión', 'Conéctate para sincronizar.');
return;
}
setSyncing(true);
try {
const report = await runSync(projectId);
await refresh();
const report = await doSync();
Alert.alert(
'Sincronización completada',
`Enviadas: ${report.applied}/${report.pushed}\n` +
@@ -55,71 +66,53 @@ export function ProjectDetailScreen({ route }: Props) {
);
} catch (e) {
Alert.alert('Error de sincronización', e instanceof Error ? e.message : String(e));
} finally {
setSyncing(false);
}
}, [projectId, refresh]);
}, [doSync]);
return (
<View style={styles.container}>
<SyncStatusBar counts={counts} syncing={syncing} />
<SyncStatusBar
counts={counts}
syncing={syncing}
onPress={() => navigation.navigate('Outbox')}
/>
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.section}>Fases ({phases.length})</Text>
{phases.map((p) => (
<View key={p.id} style={styles.card}>
<Text style={styles.cardTitle}>{p.name}</Text>
<Text style={styles.cardMeta}>{Math.round(p.progress_percent ?? 0)}%</Text>
</View>
<View style={styles.tabs}>
{TABS.map((t) => (
<TouchableOpacity
key={t}
style={[styles.tab, tab === t && styles.tabActive]}
onPress={() => setTab(t)}
>
<Text style={[styles.tabText, tab === t && styles.tabTextActive]}>{t}</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.section}>Incidencias ({issues.length})</Text>
{issues.map((i) => (
<View key={i.id} style={styles.card}>
<Text style={styles.cardTitle}>{i.title}</Text>
<Text style={styles.cardMeta}>
{i.priority ?? '—'} · {i.status ?? '—'}
</Text>
</View>
))}
<View style={styles.content} key={`${tab}-${nonce}`}>
{tab === 'Fases' && <PhasesSection projectId={projectId} />}
{tab === 'Features' && <FeaturesSection projectId={projectId} />}
{tab === 'Incidencias' && <IssuesSection projectId={projectId} />}
</View>
<Text style={styles.cursor}>
Último sync: {cursor ? new Date(cursor).toLocaleString() : 'nunca'}
</Text>
</ScrollView>
<TouchableOpacity
style={[styles.syncBtn, syncing && styles.syncBtnDisabled]}
onPress={onSync}
disabled={syncing}
>
<Text style={styles.syncBtnText}>{syncing ? 'Sincronizando…' : 'Sincronizar'}</Text>
</TouchableOpacity>
<View style={styles.footer}>
<PrimaryButton
title={syncing ? 'Sincronizando…' : 'Sincronizar'}
onPress={() => void onSync()}
loading={syncing}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
body: { padding: 16, gap: 8 },
section: { fontSize: 16, fontWeight: '700', marginTop: 12 },
card: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: '#f4f4f4',
borderRadius: 8,
padding: 12,
},
cardTitle: { fontSize: 15, flex: 1 },
cardMeta: { fontSize: 13, color: '#666', marginLeft: 8 },
cursor: { marginTop: 20, color: '#888', fontSize: 12, textAlign: 'center' },
syncBtn: {
backgroundColor: '#1f6f43',
margin: 16,
borderRadius: 8,
paddingVertical: 14,
alignItems: 'center',
},
syncBtnDisabled: { opacity: 0.5 },
syncBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' },
tabs: { flexDirection: 'row', borderBottomWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
tab: { flex: 1, paddingVertical: 12, alignItems: 'center' },
tabActive: { borderBottomWidth: 2, borderColor: COLORS.primary },
tabText: { fontSize: 14, color: COLORS.muted },
tabTextActive: { color: COLORS.primary, fontWeight: '700' },
content: { flex: 1 },
footer: { padding: 12, borderTopWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
});
+2 -1
View File
@@ -12,7 +12,7 @@ import {
import * as api from '../api/endpoints';
import { Project } from '../api/types';
import { useSession } from '../auth/session';
import { getCursor, getProjects, saveProjectList } from '../db/repositories';
import { getCursor, getProjects, saveProjectList, setActiveProjectId } from '../db/repositories';
import { isOnline } from '../net/connectivity';
import { initialPull, runSync } from '../sync/engine';
import { RootStackParamList } from '../navigation/types';
@@ -52,6 +52,7 @@ export function ProjectsScreen({ navigation }: Props) {
async (p: Project) => {
setOpening(p.id);
try {
await setActiveProjectId(p.id);
const online = await isOnline();
if (online) {
const cursor = await getCursor(p.id);
+166
View File
@@ -0,0 +1,166 @@
/**
* Contenido del detalle de una feature: estado/progreso editable, inspecciones
* y fotos. Reutilizado en móvil (pantalla) y tablet (panel del maestro-detalle).
*/
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 { hasPermission, useSession } from '../../auth/session';
import { getFeature, getInspectionsByFeature } from '../../db/repositories';
import { updateFeature } from '../../sync/mutations';
import { RootStackParamList } from '../../navigation/types';
import {
Badge,
Card,
ChipSelect,
COLORS,
EmptyState,
PrimaryButton,
SectionTitle,
} from '../../ui/components';
import { MediaStrip } from '../../ui/MediaStrip';
const STATUSES = ['pending', 'in_progress', 'completed', 'blocked'] as const;
const QUICK_PROGRESS = [0, 25, 50, 75, 100];
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function FeatureDetailContent({ featureId }: { featureId: number }) {
const navigation = useNavigation<Nav>();
const { user } = useSession();
const canProgress = hasPermission(user, 'update progress');
const canInspect = hasPermission(user, 'create inspections');
const [feature, setFeature] = useState<Feature | null>(null);
const [inspections, setInspections] = useState<Inspection[]>([]);
const refresh = useCallback(async () => {
const [f, ins] = await Promise.all([
getFeature(featureId),
getInspectionsByFeature(featureId),
]);
setFeature(f);
setInspections(ins);
}, [featureId]);
useEffect(() => {
void refresh();
}, [refresh]);
const onStatus = useCallback(
async (status: string) => {
await updateFeature({ id: featureId, status });
await refresh();
},
[featureId, refresh],
);
const onProgress = useCallback(
async (progress: number) => {
await updateFeature({ id: featureId, progress });
await refresh();
},
[featureId, refresh],
);
if (!feature) {
return (
<View style={styles.center}>
<Text style={{ color: COLORS.muted }}>Cargando</Text>
</View>
);
}
return (
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.title}>{feature.name}</Text>
<View style={styles.badges}>
{feature.status && <Badge label={feature.status} color={COLORS.primary} />}
<Badge label={`${Math.round(feature.progress ?? 0)}%`} color={COLORS.muted} />
</View>
<MediaStrip
parentEntity="feature"
parentId={feature.id}
canUpload={hasPermission(user, 'upload media')}
/>
{canProgress && (
<Card style={styles.editCard}>
<ChipSelect
label="Estado"
value={feature.status as (typeof STATUSES)[number] | undefined}
options={STATUSES}
onChange={(v) => void onStatus(v)}
/>
<Text style={styles.fieldLabel}>Progreso</Text>
<View style={styles.progressRow}>
{QUICK_PROGRESS.map((p) => {
const active = Math.round(feature.progress ?? 0) === p;
return (
<TouchableOpacity
key={p}
style={[styles.pBtn, active && styles.pBtnActive]}
onPress={() => void onProgress(p)}
>
<Text style={[styles.pText, active && styles.pTextActive]}>{p}%</Text>
</TouchableOpacity>
);
})}
</View>
</Card>
)}
<SectionTitle>Inspecciones ({inspections.length})</SectionTitle>
{inspections.length === 0 && <EmptyState text="Sin inspecciones." />}
{inspections.map((ins) => (
<Card key={ins.id} style={styles.insCard}>
<Text style={styles.insTitle}>
{ins.result ?? ins.status ?? 'Inspección'} ·{' '}
{ins.created_at ? new Date(ins.created_at).toLocaleDateString() : ''}
</Text>
{ins.notes ? <Text style={styles.insNotes}>{ins.notes}</Text> : null}
</Card>
))}
{canInspect && (
<PrimaryButton
title="Nueva inspección"
variant="ghost"
onPress={() =>
navigation.navigate('InspectionForm', {
featureId: feature.id,
featureName: feature.name,
templateId: feature.template_id ?? undefined,
})
}
/>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
body: { padding: 16, gap: 6 },
title: { fontSize: 20, fontWeight: '700' },
badges: { flexDirection: 'row', gap: 6, marginTop: 6 },
editCard: { marginTop: 12 },
fieldLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 6, fontWeight: '600' },
progressRow: { flexDirection: 'row', gap: 6, flexWrap: 'wrap' },
pBtn: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 1,
borderColor: COLORS.border,
backgroundColor: '#fff',
},
pBtnActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
pText: { color: COLORS.muted, fontWeight: '600' },
pTextActive: { color: '#fff' },
insCard: { marginBottom: 6 },
insTitle: { fontSize: 14, fontWeight: '600' },
insNotes: { fontSize: 13, color: COLORS.muted, marginTop: 2 },
});
+233
View File
@@ -0,0 +1,233 @@
/**
* Contenido del detalle de una incidencia. Se reutiliza en móvil (pantalla
* navegada) y en tablet (panel derecho del maestro-detalle).
*
* Todas las acciones pasan por src/sync/mutations.ts: escriben en local y
* encolan la operación. Tras cada cambio refrescamos desde la BD.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import {
Issue,
IssueComment,
IssuePriority,
IssueStatus,
IssueTask,
} from '../../api/types';
import { hasPermission, useSession } from '../../auth/session';
import {
getIssue,
getIssueComments,
getIssueTasks,
} from '../../db/repositories';
import {
createIssueComment,
createIssueTask,
updateIssue,
updateIssueTask,
} from '../../sync/mutations';
import {
Badge,
Card,
ChipSelect,
COLORS,
EmptyState,
Field,
ISSUE_PRIORITY_COLOR,
ISSUE_STATUS_COLOR,
PrimaryButton,
SectionTitle,
} from '../../ui/components';
import { MediaStrip } from '../../ui/MediaStrip';
const STATUSES: readonly IssueStatus[] = ['open', 'in_review', 'resolved', 'closed'];
const PRIORITIES: readonly IssuePriority[] = ['low', 'medium', 'high', 'critical'];
export function IssueDetailContent({ issueId }: { issueId: number }) {
const { user } = useSession();
const canEdit = hasPermission(user, 'edit issues');
const [issue, setIssue] = useState<Issue | null>(null);
const [tasks, setTasks] = useState<IssueTask[]>([]);
const [comments, setComments] = useState<IssueComment[]>([]);
const [newTask, setNewTask] = useState('');
const [newComment, setNewComment] = useState('');
const refresh = useCallback(async () => {
const [i, t, c] = await Promise.all([
getIssue(issueId),
getIssueTasks(issueId),
getIssueComments(issueId),
]);
setIssue(i);
setTasks(t);
setComments(c);
}, [issueId]);
useEffect(() => {
void refresh();
}, [refresh]);
const onStatus = useCallback(
async (status: IssueStatus) => {
await updateIssue({ id: issueId, status });
await refresh();
},
[issueId, refresh],
);
const onPriority = useCallback(
async (priority: IssuePriority) => {
await updateIssue({ id: issueId, priority });
await refresh();
},
[issueId, refresh],
);
const onToggleTask = useCallback(
async (task: IssueTask) => {
await updateIssueTask({ id: task.id, is_done: !task.is_done });
await refresh();
},
[refresh],
);
const onAddTask = useCallback(async () => {
const title = newTask.trim();
if (!title) return;
setNewTask('');
await createIssueTask({ issue_id: issueId, title });
await refresh();
}, [newTask, issueId, refresh]);
const onAddComment = useCallback(async () => {
const body = newComment.trim();
if (!body) return;
setNewComment('');
await createIssueComment({ issue_id: issueId, body });
await refresh();
}, [newComment, issueId, refresh]);
if (!issue) {
return (
<View style={styles.center}>
<Text style={{ color: COLORS.muted }}>Cargando</Text>
</View>
);
}
const done = tasks.filter((t) => t.is_done).length;
const progress = tasks.length ? Math.round((done / tasks.length) * 100) : 0;
return (
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.title}>{issue.title}</Text>
<View style={styles.badges}>
{issue.status && (
<Badge label={issue.status} color={ISSUE_STATUS_COLOR[issue.status] ?? COLORS.muted} />
)}
{issue.priority && (
<Badge
label={issue.priority}
color={ISSUE_PRIORITY_COLOR[issue.priority] ?? COLORS.muted}
/>
)}
{issue.type && <Badge label={issue.type} color={COLORS.muted} />}
</View>
{issue.description ? <Text style={styles.desc}>{issue.description}</Text> : null}
<MediaStrip parentEntity="issue" parentId={issue.id} canUpload={hasPermission(user, 'upload media')} />
{canEdit && (
<Card style={styles.editCard}>
<ChipSelect
label="Estado"
value={issue.status}
options={STATUSES}
onChange={(v) => void onStatus(v)}
/>
<ChipSelect
label="Prioridad"
value={issue.priority}
options={PRIORITIES}
onChange={(v) => void onPriority(v)}
/>
</Card>
)}
<SectionTitle>
Tareas · {done}/{tasks.length} ({progress}%)
</SectionTitle>
{tasks.length === 0 && <EmptyState text="Sin tareas." />}
{tasks.map((t) => (
<TouchableOpacity
key={t.id}
style={styles.taskRow}
disabled={!canEdit}
onPress={() => void onToggleTask(t)}
>
<Text style={styles.checkbox}>{t.is_done ? '☑' : '☐'}</Text>
<Text style={[styles.taskText, t.is_done && styles.taskDone]}>{t.title}</Text>
</TouchableOpacity>
))}
{canEdit && (
<View style={styles.addRow}>
<View style={{ flex: 1 }}>
<Field
label=""
placeholder="Nueva tarea…"
value={newTask}
onChangeText={setNewTask}
onSubmitEditing={() => void onAddTask()}
/>
</View>
<PrimaryButton title="Añadir" onPress={() => void onAddTask()} disabled={!newTask.trim()} />
</View>
)}
<SectionTitle>Comentarios</SectionTitle>
{comments.length === 0 && <EmptyState text="Sin comentarios." />}
{comments.map((c) => (
<Card key={c.id} style={styles.comment}>
<Text style={styles.commentBody}>{c.body}</Text>
{c.created_at ? (
<Text style={styles.commentMeta}>{new Date(c.created_at).toLocaleString()}</Text>
) : null}
</Card>
))}
<View style={styles.addRow}>
<View style={{ flex: 1 }}>
<Field
label=""
placeholder="Escribe un comentario…"
value={newComment}
onChangeText={setNewComment}
multiline
/>
</View>
<PrimaryButton
title="Enviar"
onPress={() => void onAddComment()}
disabled={!newComment.trim()}
/>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
body: { padding: 16, gap: 4 },
title: { fontSize: 20, fontWeight: '700' },
badges: { flexDirection: 'row', gap: 6, marginTop: 6, flexWrap: 'wrap' },
desc: { marginTop: 8, color: '#333', fontSize: 14 },
editCard: { marginTop: 12, gap: 4 },
taskRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: 10 },
checkbox: { fontSize: 20 },
taskText: { fontSize: 15, flex: 1 },
taskDone: { textDecorationLine: 'line-through', color: COLORS.muted },
addRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, marginTop: 4 },
comment: { marginBottom: 6 },
commentBody: { fontSize: 14 },
commentMeta: { fontSize: 11, color: COLORS.muted, marginTop: 4 },
});
+115
View File
@@ -0,0 +1,115 @@
/**
* Sección Features: maestro-detalle con conmutador Lista/Mapa en el panel
* maestro. Lista o mapa a la izquierda, detalle a la derecha (tablet) o
* navegación a pantalla (móvil).
*/
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import React, { useCallback, useState } from 'react';
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Feature } from '../../api/types';
import { getFeatures } from '../../db/repositories';
import { RootStackParamList } from '../../navigation/types';
import { Badge, COLORS, EmptyState } from '../../ui/components';
import { FeatureMap } from '../../ui/FeatureMap';
import { MasterDetail } from '../../ui/MasterDetail';
import { FeatureDetailContent } from '../detail/FeatureDetailContent';
type Nav = NativeStackNavigationProp<RootStackParamList>;
type ViewMode = 'list' | 'map';
export function FeaturesSection({ projectId }: { projectId: number }) {
const navigation = useNavigation<Nav>();
const [features, setFeatures] = useState<Feature[]>([]);
const [mode, setMode] = useState<ViewMode>('list');
const load = useCallback(() => {
void getFeatures(projectId).then(setFeatures);
}, [projectId]);
useFocusEffect(load);
const goPhone = (id: number) => {
const f = features.find((x) => x.id === id);
navigation.navigate('FeatureDetail', { featureId: id, name: f?.name ?? 'Feature' });
};
return (
<MasterDetail
onSelectPhone={goPhone}
renderDetail={(id) => <FeatureDetailContent featureId={id} />}
renderMaster={({ selectedId, onSelect }) => (
<View style={{ flex: 1 }}>
<View style={styles.toggle}>
{(['list', 'map'] as ViewMode[]).map((m) => (
<TouchableOpacity
key={m}
style={[styles.toggleBtn, mode === m && styles.toggleActive]}
onPress={() => setMode(m)}
>
<Text style={[styles.toggleText, mode === m && styles.toggleTextActive]}>
{m === 'list' ? 'Lista' : 'Mapa'}
</Text>
</TouchableOpacity>
))}
</View>
{features.length === 0 ? (
<EmptyState text="Sin features." />
) : mode === 'map' ? (
<FeatureMap features={features} selectedId={selectedId} onSelect={onSelect} />
) : (
<FlatList
data={features}
keyExtractor={(f) => String(f.id)}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.row, item.id === selectedId && styles.rowActive]}
onPress={() => onSelect(item.id)}
>
<View style={{ flex: 1 }}>
<Text style={styles.name}>{item.name}</Text>
{item.status ? <Text style={styles.meta}>{item.status}</Text> : null}
</View>
<Badge label={`${Math.round(item.progress ?? 0)}%`} color={COLORS.muted} />
</TouchableOpacity>
)}
/>
)}
</View>
)}
/>
);
}
const styles = StyleSheet.create({
toggle: {
flexDirection: 'row',
padding: 8,
gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
},
toggleBtn: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: COLORS.border,
},
toggleActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
toggleText: { fontSize: 13, color: COLORS.muted },
toggleTextActive: { color: '#fff', fontWeight: '700' },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
gap: 8,
},
rowActive: { backgroundColor: '#eef5f0' },
name: { fontSize: 15, fontWeight: '600' },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Sección Incidencias: maestro-detalle + alta de incidencia.
*/
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import React, { useCallback, useState } from 'react';
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Issue } from '../../api/types';
import { hasPermission, useSession } from '../../auth/session';
import { getIssues } from '../../db/repositories';
import { RootStackParamList } from '../../navigation/types';
import {
Badge,
COLORS,
EmptyState,
ISSUE_PRIORITY_COLOR,
ISSUE_STATUS_COLOR,
PrimaryButton,
} from '../../ui/components';
import { MasterDetail } from '../../ui/MasterDetail';
import { IssueDetailContent } from '../detail/IssueDetailContent';
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function IssuesSection({ projectId }: { projectId: number }) {
const navigation = useNavigation<Nav>();
const { user } = useSession();
const canCreate = hasPermission(user, 'create issues');
const [issues, setIssues] = useState<Issue[]>([]);
const load = useCallback(() => {
void getIssues(projectId).then(setIssues);
}, [projectId]);
useFocusEffect(load);
return (
<View style={{ flex: 1 }}>
{canCreate && (
<View style={styles.toolbar}>
<PrimaryButton
title="+ Nueva incidencia"
variant="ghost"
onPress={() => navigation.navigate('IssueCreate', { projectId })}
/>
</View>
)}
<MasterDetail
onSelectPhone={(id) => {
const i = issues.find((x) => x.id === id);
navigation.navigate('IssueDetail', { issueId: id, title: i?.title ?? 'Incidencia' });
}}
renderDetail={(id) => <IssueDetailContent issueId={id} />}
renderMaster={({ selectedId, onSelect }) =>
issues.length === 0 ? (
<EmptyState text="Sin incidencias." />
) : (
<FlatList
data={issues}
keyExtractor={(i) => String(i.id)}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.row, item.id === selectedId && styles.rowActive]}
onPress={() => onSelect(item.id)}
>
<View style={{ flex: 1 }}>
<Text style={styles.title} numberOfLines={1}>
{item.title}
</Text>
<View style={styles.badges}>
{item.priority && (
<Badge
label={item.priority}
color={ISSUE_PRIORITY_COLOR[item.priority] ?? COLORS.muted}
/>
)}
{item.status && (
<Badge
label={item.status}
color={ISSUE_STATUS_COLOR[item.status] ?? COLORS.muted}
/>
)}
{item.id < 0 && <Badge label="local" color={COLORS.warn} />}
</View>
</View>
</TouchableOpacity>
)}
/>
)
}
/>
</View>
);
}
const styles = StyleSheet.create({
toolbar: { padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
row: {
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
},
rowActive: { backgroundColor: '#eef5f0' },
title: { fontSize: 15, fontWeight: '600' },
badges: { flexDirection: 'row', gap: 6, marginTop: 6 },
});
+93
View File
@@ -0,0 +1,93 @@
/**
* 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' },
});