89dec6da7e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
412 lines
20 KiB
React
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>
|
|
);
|
|
}
|