Files
avante-movil/src/screens/detail/IssueDetailContent.tsx
T

233 lines
7.0 KiB
TypeScript

/**
* 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,
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: '#666' }}>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] ?? '#666'} />
)}
{issue.priority && (
<Badge
label={issue.priority}
color={ISSUE_PRIORITY_COLOR[issue.priority] ?? '#666'}
/>
)}
{issue.type && <Badge label={issue.type} color="#666" />}
</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: '#666' }, // muted
addRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, marginTop: 4 },
comment: { marginBottom: 6 },
commentBody: { fontSize: 14 },
commentMeta: { fontSize: 11, color: '#666', marginTop: 4 }, // muted
});