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:
co-authored by
Claude Opus 4.8
parent
4e9c7d059f
commit
9bcc51e3b2
@@ -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} />;
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
Reference in New Issue
Block a user