5e0bc0809e
No existia forma de borrar una actividad. Agregado DELETE /api/activities/:id con boton "Eliminar" en el listado (popup de confirmacion). Si tiene tareas, se bloquea con 400 y mensaje claro en vez de arrastrar todo en cascada sin avisar. De paso: la logica de borrar adjunto (fila + archivo en disco) se extrajo a un attachments/service.js reutilizable, usado tanto por el borrado individual como por el borrado en cascada de una actividad (evita archivos huerfanos). Comentarios de la actividad tambien se limpian (sin FK fisica cruzada, entity_type/entity_id es polimorfico). Probado en dev con curl (bloquea con tarea, permite sin tareas, 404 tras borrar) y Playwright (mensaje de advertencia visible). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
166 lines
7.5 KiB
React
166 lines
7.5 KiB
React
import { useState } from 'react';
|
|
import { Plus, Save, Trash2 } 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 { listActivitiesByProject, createActivity, getActivity, deleteActivity } from '../../services/activities.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 { 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';
|
|
|
|
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
|
|
|
|
export default function ProjectActivitiesTab({ projectId }) {
|
|
const { t, pushToast, openDialog } = useUi();
|
|
const { user: currentUser } = useAuth();
|
|
const runAction = useAction();
|
|
const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
|
|
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
|
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
|
|
const { data: users } = useAsync(listUsers, []);
|
|
|
|
const [search, setSearch] = useState('');
|
|
const [creating, setCreating] = useState(false);
|
|
const [form, setForm] = useState(EMPTY_FORM);
|
|
const [selected, setSelected] = useState(null);
|
|
|
|
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 }));
|
|
|
|
async function save() {
|
|
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
|
|
await runAction(async () => {
|
|
await createActivity(projectId, { ...form, responsibleUserId: form.responsibleUserId || null });
|
|
setCreating(false);
|
|
setForm(EMPTY_FORM);
|
|
pushToast({ tone: 'success', message: t('common_saved') });
|
|
await reload();
|
|
});
|
|
}
|
|
|
|
async function openActivity(row) {
|
|
const full = await getActivity(row.id);
|
|
setSelected(full);
|
|
}
|
|
|
|
async function refreshAll() {
|
|
await reload();
|
|
if (selected) setSelected(await getActivity(selected.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> },
|
|
];
|
|
|
|
if (!activities) return <div className="text-muted">{t('common_loading')}</div>;
|
|
|
|
return (
|
|
<div>
|
|
<div className="toolbar">
|
|
<SearchInput value={search} onChange={setSearch} />
|
|
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button>
|
|
</div>
|
|
|
|
<ListView
|
|
columns={columns}
|
|
rows={activities}
|
|
searchValue={search}
|
|
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
|
|
getRowKey={(a) => a.id}
|
|
onRowClick={openActivity}
|
|
rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [
|
|
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) },
|
|
] : undefined}
|
|
/>
|
|
|
|
<DetailPopup open={creating} title={t('activity_new')} onClose={() => setCreating(false)} width={640} height={560}>
|
|
<Field label={t('common_name')}>
|
|
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
|
</Field>
|
|
<Field label={t('common_description')}>
|
|
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
|
|
</Field>
|
|
<FieldRow>
|
|
<Field label={t('common_responsible')}>
|
|
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
|
|
</Field>
|
|
<Field label={t('common_priority')}>
|
|
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
|
|
</Field>
|
|
<Field label={t('common_status')}>
|
|
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
|
|
</Field>
|
|
</FieldRow>
|
|
<FieldRow>
|
|
<Field label={t('activity_start_date')}>
|
|
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
|
</Field>
|
|
<Field label={t('activity_due_date')}>
|
|
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
|
|
</Field>
|
|
<Field label={t('activity_milestone')}>
|
|
<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={() => setCreating(false)}>{t('common_cancel')}</Button>
|
|
</div>
|
|
</DetailPopup>
|
|
|
|
<DetailPopup open={!!selected} title={selected ? `${selected.code} — ${selected.name}` : ''} onClose={() => setSelected(null)} width={720} height={680}>
|
|
{selected && (
|
|
<ActivityDetailPanel
|
|
activity={selected}
|
|
projectId={projectId}
|
|
siblingActivities={activities}
|
|
onChanged={refreshAll}
|
|
/>
|
|
)}
|
|
</DetailPopup>
|
|
</div>
|
|
);
|
|
}
|