233 lines
7.0 KiB
TypeScript
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
|
|
});
|