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