Files
project.linkedcorp.net/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx
T
2026-10-23 15:55:54 -05:00

412 lines
20 KiB
React

import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Plus, Save, Trash2, ArrowRightLeft, FolderUp, Archive, RotateCcw, Sparkles } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity, convertActivityToProject, listActivitiesTrash, restoreActivity } from '../../services/activities.js';
import { listProjectOptions } from '../../services/projects.js';
import { search as searchApi } from '../../services/search.js';
import { listCatalog } from '../../services/catalogs.js';
import { listUsers } from '../../services/users.js';
import { ListView } from '../../components/ListView.jsx';
import { SearchInput } from '../../components/SearchInput.jsx';
import { SearchResultsDropdown } from '../../components/SearchResultsDropdown.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar, DueBadge } from '../../components/StatusBadge.jsx';
import { dueStatus } from '../../utils/dueStatus.js';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
import { AiPlanDialog } from './AiPlanDialog.jsx';
import { getAiStatus } from '../../services/ai.js';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor } from '../../hooks/useDraftForm.js';
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
export default function ProjectActivitiesTab({ project, projectId }) {
const { t, pushToast, openDialog } = useUi();
const { user: currentUser } = useAuth();
const navigate = useNavigate();
const runAction = useAction();
const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
// Boton de IA solo si el superadmin configuro la clave; el backend valida permisos al usarlo.
const { data: aiStatus } = useAsync(() => getAiStatus().catch(() => ({ enabled: false })), []);
const [aiPlanOpen, setAiPlanOpen] = useState(false);
const [trashOpen, setTrashOpen] = useState(false);
const { data: trashed, reload: reloadTrash } = useAsync(
() => (trashOpen ? listActivitiesTrash(projectId) : Promise.resolve([])),
[trashOpen, projectId]
);
function restoreFromTrash(activity) {
runAction(async () => {
await restoreActivity(activity.id);
await reloadTrash();
reload();
pushToast({ tone: 'success', message: t('common_saved') });
});
}
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
const { data: users } = useAsync(listUsers, []);
const { data: allProjects } = useAsync(listProjectOptions, []);
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
const [selected, setSelected] = useState(null);
const [moving, setMoving] = useState(null);
const [targetProjectId, setTargetProjectId] = useState('');
const [converting, setConverting] = useState(null);
const drafts = useUserDrafts();
const guardedClose = useGuardedClose();
// "Nueva actividad" con borrador (por proyecto); se reabre al volver si quedo a medio llenar.
const draft = useDraftForm({ formId: 'activity-new', entityId: projectId, initial: EMPTY_FORM, label: `${project.code} — ${t('activity_new')}`, enabled: creating });
const form = draft.values;
const setForm = draft.setValues;
const pendingNew = hasDraftFor(drafts, 'activity-new', projectId);
useEffect(() => { if (pendingNew) setCreating(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
// "Convertir en proyecto" con borrador (por actividad).
const convertDraft = useDraftForm({
formId: 'activity-convert', entityId: converting?.id,
initial: { projectName: converting?.name || '', taskActivityName: converting?.name || '' },
label: `${converting?.code || ''} — ${t('activity_convert_title')}`, enabled: !!converting,
});
const convertForm = convertDraft.values;
const setConvertForm = convertDraft.setValues;
// Actividad abierta y su pestaña viven en la URL (?activity=&activityTab=): F5, el retorno tras
// el login y el aviso de cambios sin guardar (al cerrar el popup o cambiar de pestaña) usan eso.
const [searchParams, setSearchParams] = useSearchParams();
const selectedId = searchParams.get('activity') ? Number(searchParams.get('activity')) : null;
const activityTab = searchParams.get('activityTab') || 'detail';
const setActivityParams = (id, tab) => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (id) {
next.set('activity', id);
if (tab && tab !== 'detail') next.set('activityTab', tab); else next.delete('activityTab');
} else {
next.delete('activity');
next.delete('activityTab');
}
return next;
}, { replace: true });
useEffect(() => {
if (!selectedId) { setSelected(null); return undefined; }
let alive = true;
// Si la actividad ya no existe o no hay acceso, no se abre (ni se restauran sus borradores).
getActivity(selectedId)
.then((a) => { if (alive) setSelected(a); })
.catch(() => { if (alive) setActivityParams(null); });
return () => { alive = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId]);
// Busca tambien tareas de esta actividad (no solo actividades, que ya filtra la tabla mas abajo
// via getSearchHaystack) — resultados con su ruta "Actividad - Tarea" en un panel flotante.
const debouncedSearch = useDebouncedValue(search, 300);
const searchQuery = debouncedSearch.trim();
const { data: searchResults } = useAsync(
() => (searchQuery.length >= 2 ? searchApi(searchQuery, projectId) : Promise.resolve(null)),
[searchQuery, projectId]
);
const searchItems = searchResults ? [
...searchResults.activities.map((a) => ({
key: `a-${a.id}`,
breadcrumb: [`${a.code} — ${a.name}`],
onClick: () => { setSearch(''); openActivity({ id: a.id }); },
})),
...searchResults.tasks.map((tk) => ({
key: `t-${tk.id}`,
breadcrumb: [`${tk.activity_code} — ${tk.activity_name}`, tk.name],
onClick: () => { setSearch(''); openActivity({ id: tk.activity_id }, 'tasks'); },
})),
] : null;
// Al volver con un borrador de "Convertir en proyecto", se reabre sobre su actividad.
useEffect(() => {
if (!activities || converting) return;
const pending = drafts.find((d) => d.formId === 'activity-convert');
const row = pending && activities.find((a) => String(a.id) === String(pending.entityId));
if (row) setConverting(row);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activities]);
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
const projectOptions = (allProjects || [])
.filter((p) => p.id !== Number(projectId))
.map((p) => ({ value: p.id, label: `${p.code} — ${p.name}` }));
async function save() {
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
await runAction(async () => {
await createActivity(projectId, { ...form, responsibleUserId: form.responsibleUserId || null });
draft.clear();
setCreating(false);
pushToast({ tone: 'success', message: t('common_saved') });
await reload();
});
}
function openActivity(row, initialTab) {
setActivityParams(row.id, initialTab);
}
async function refreshAll() {
await reload();
if (selected) setSelected(await getActivity(selected.id));
}
async function handleReorder(ids) {
await reorderActivities(projectId, ids);
await reload();
}
function startMove(row) {
setMoving(row);
setTargetProjectId('');
}
function confirmMove() {
const target = (allProjects || []).find((p) => p.id === Number(targetProjectId));
if (!target) return;
const message = t('activity_move_confirm_msg').replace('{activity}', moving.name).replace('{project}', target.name);
openDialog({
tone: 'warning',
title: t('activity_move_title'),
message,
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('common_accept'), variant: 'accent',
onClick: () => runAction(async () => {
await moveActivity(moving.id, Number(targetProjectId));
setMoving(null);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}),
},
],
});
}
function startConvert(row) {
setConverting(row);
}
function confirmConvert() {
const { projectName, taskActivityName } = convertForm;
if (!projectName.trim() || !taskActivityName.trim()) return;
const message = t('activity_convert_confirm_msg')
.replace('{activity}', converting.name)
.replace('{project}', projectName.trim())
.replace('{newActivity}', taskActivityName.trim());
openDialog({
tone: 'warning',
title: t('activity_convert_title'),
message,
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('common_accept'), variant: 'accent',
onClick: () => runAction(async () => {
const result = await convertActivityToProject(converting.id, projectName.trim(), taskActivityName.trim());
convertDraft.clear();
setConverting(null);
pushToast({ tone: 'success', message: t('common_saved') });
navigate(`/proyectos/${result.project.id}`);
}),
},
],
});
}
function confirmDelete(row) {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
message: t('common_confirm_delete_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('common_delete'), variant: 'danger',
onClick: () => runAction(async () => {
await deleteActivity(row.id);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}),
},
],
});
}
const columns = [
{ key: 'code', label: t('activity_code') },
{ key: 'name', label: t('common_name') },
{ key: 'responsible_name', label: t('common_responsible'), render: (r) => r.responsible_name || '—' },
{ key: 'status_name', label: t('common_status'), render: (r) => <StatusBadge name={r.status_name} color={r.status_color} /> },
{ key: 'priority_name', label: t('common_priority'), render: (r) => <StatusBadge name={r.priority_name} color={r.priority_color} /> },
{ key: 'due_date', label: t('activity_due_date'), render: (r) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{r.due_date || '—'}</span>
<DueBadge status={dueStatus(r.due_date, r.status_is_closed)} />
</div>
) },
{ key: 'progress_pct', label: t('activity_progress'), render: (r) => <div style={{ width: 100 }}><ProgressBar value={Number(r.progress_pct)} /></div> },
{ key: 'updated_at', label: t('activity_updated_at'), render: (r) => <span style={{ whiteSpace: 'nowrap' }}>{String(r.updated_at).slice(0, 16)}</span> },
];
if (!activities) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div className="toolbar">
<div style={{ position: 'relative' }}>
<SearchInput value={search} onChange={setSearch} />
{searchQuery.length >= 2 && searchItems && (
<SearchResultsDropdown items={searchItems} emptyLabel={t('common_search_no_results')} />
)}
</div>
{project?.isOwner && (
<Button variant="standard" icon={Archive} onClick={() => setTrashOpen(true)}>{t('activities_trash')}</Button>
)}
{aiStatus?.enabled && (
<Button variant="standard" icon={Sparkles} onClick={() => setAiPlanOpen(true)}>{t('ai_plan_button')}</Button>
)}
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}{pendingNew && !creating && <DraftMark />}</Button>
</div>
<AiPlanDialog open={aiPlanOpen} projectId={projectId} onClose={() => setAiPlanOpen(false)} onCreated={reload} />
<DetailPopup open={trashOpen} title={t('activities_trash')} onClose={() => setTrashOpen(false)} width={480} height={420}>
{(trashed || []).length === 0 && <div className="text-muted" style={{ fontSize: '.82rem' }}>{t('activities_trash_empty')}</div>}
{(trashed || []).map((a) => (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0', borderBottom: '1px solid rgb(var(--line))' }}>
<span style={{ flex: 1, fontSize: '.82rem' }}>{a.code} — {a.name}</span>
<Button variant="standard" icon={RotateCcw} onClick={() => restoreFromTrash(a)}>{t('common_restore')}</Button>
</div>
))}
</DetailPopup>
<ListView
columns={columns}
rows={activities}
searchValue={search}
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
getRowKey={(a) => a.id}
onRowClick={openActivity}
onReorder={currentUser?.roleCode !== 'viewer' ? handleReorder : undefined}
rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [
{ label: t('activity_move'), icon: ArrowRightLeft, onClick: () => startMove(a) },
{ label: t('activity_convert'), icon: FolderUp, onClick: () => startConvert(a) },
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) },
] : undefined}
/>
<DetailPopup open={!!moving} title={t('activity_move_title')} onClose={() => setMoving(null)} width={480} height={260}>
{moving && (
<>
<Field label={t('activity_move_target')}>
<ComboBox options={projectOptions} value={targetProjectId} onChange={setTargetProjectId} />
</Field>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="accent" icon={ArrowRightLeft} onClick={confirmMove} disabled={!targetProjectId}>{t('activity_move')}</Button>
<Button variant="standard" onClick={() => setMoving(null)}>{t('common_cancel')}</Button>
</div>
</>
)}
</DetailPopup>
<DetailPopup open={!!converting} title={t('activity_convert_title')} onClose={() => guardedClose(convertDraft, () => setConverting(null))} width={520} height={400}>
{converting && (
<>
<DraftNotice draft={convertDraft} />
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 12 }}>{t('activity_convert_hint')}</div>
<Field label={t('activity_convert_project_name')} dirty={convertDraft.isFieldDirty('projectName')}>
<input
className="input" value={convertForm.projectName} autoFocus
onChange={(e) => setConvertForm((f) => ({ ...f, projectName: e.target.value }))}
/>
</Field>
<Field label={t('activity_convert_activity_name')} dirty={convertDraft.isFieldDirty('taskActivityName')}>
<input
className="input" value={convertForm.taskActivityName}
onChange={(e) => setConvertForm((f) => ({ ...f, taskActivityName: e.target.value }))}
/>
</Field>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button
variant="accent" icon={FolderUp} onClick={confirmConvert}
disabled={!convertForm.projectName.trim() || !convertForm.taskActivityName.trim()}
>
{t('activity_convert')}
</Button>
<Button variant="standard" onClick={() => { convertDraft.discard(); setConverting(null); }}>{t('common_cancel')}</Button>
</div>
</>
)}
</DetailPopup>
<DetailPopup open={creating} title={t('activity_new')} onClose={() => guardedClose(draft, () => setCreating(false))} width={640} height={600}>
<DraftNotice draft={draft} />
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('common_description')} dirty={draft.isFieldDirty('description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('common_responsible')} dirty={draft.isFieldDirty('responsibleUserId')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
<Field label={t('common_priority')} dirty={draft.isFieldDirty('priorityId')}>
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
</Field>
<Field label={t('common_status')} dirty={draft.isFieldDirty('statusId')}>
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('activity_start_date')} dirty={draft.isFieldDirty('startDate')}>
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
</Field>
<Field label={t('activity_due_date')} dirty={draft.isFieldDirty('dueDate')}>
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
</Field>
<Field label={t('activity_milestone')} dirty={draft.isFieldDirty('isMilestone')}>
<label className="checkbox-row">
<input type="checkbox" checked={form.isMilestone} onChange={(e) => setForm((f) => ({ ...f, isMilestone: e.target.checked }))} />
{t('activity_milestone')}
</label>
</Field>
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => { draft.discard(); setCreating(false); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
<DetailPopup open={!!selected} title={selected ? `${selected.code} — ${selected.name}` : ''} onClose={() => setActivityParams(null)} width={720} height={680}>
{selected && (
<ActivityDetailPanel
key={selected.id}
activity={selected}
projectId={projectId}
siblingActivities={activities}
onChanged={refreshAll}
tab={activityTab}
onTabChange={(key) => setActivityParams(selected.id, key)}
/>
)}
</DetailPopup>
</div>
);
}