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) => }, { key: 'priority_name', label: t('common_priority'), render: (r) => }, { key: 'due_date', label: t('activity_due_date'), render: (r) => (
{r.due_date || '—'}
) }, { key: 'progress_pct', label: t('activity_progress'), render: (r) =>
}, { key: 'updated_at', label: t('activity_updated_at'), render: (r) => {String(r.updated_at).slice(0, 16)} }, ]; if (!activities) return
{t('common_loading')}
; return (
{searchQuery.length >= 2 && searchItems && ( )}
{project?.isOwner && ( )} {aiStatus?.enabled && ( )}
setAiPlanOpen(false)} onCreated={reload} /> setTrashOpen(false)} width={480} height={420}> {(trashed || []).length === 0 &&
{t('activities_trash_empty')}
} {(trashed || []).map((a) => (
{a.code} — {a.name}
))}
`${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} /> setMoving(null)} width={480} height={260}> {moving && ( <>
)}
guardedClose(convertDraft, () => setConverting(null))} width={520} height={400}> {converting && ( <>
{t('activity_convert_hint')}
setConvertForm((f) => ({ ...f, projectName: e.target.value }))} /> setConvertForm((f) => ({ ...f, taskActivityName: e.target.value }))} />
)}
guardedClose(draft, () => setCreating(false))} width={640} height={600}> setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />