feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia
Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico, revocacion de JWT por version de sesion (028), control optimista de edicion (029), logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion de proyectos, accesibilidad de dialogos/controles e i18n. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,9 @@ import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||
|
||||
const COLOR_OPTIONS = ['accent', 'success', 'warning', 'danger', 'muted'].map((c) => ({ value: c, label: c }));
|
||||
|
||||
@@ -25,30 +28,39 @@ export function CatalogEditor({ catalogKey, fields, title }) {
|
||||
const { data: items, reload } = useAsync(() => listCatalog(catalogKey), [catalogKey]);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm(fields));
|
||||
const guardedClose = useGuardedClose();
|
||||
// Borrador del item (nuevo o edicion), separado por catalogo.
|
||||
const fromItem = (item) => { const next = {}; for (const f of fields) next[f.key] = item[f.key]; next.editVersion = item.edit_version; return next; };
|
||||
const draft = useDraftForm({
|
||||
formId: `catalog-${catalogKey}-${selected ? 'edit' : 'new'}`, entityId: selected?.id ?? null,
|
||||
initial: selected ? fromItem(selected) : emptyForm(fields),
|
||||
label: `${title}: ${selected ? t('common_edit') : t('common_new')}`, enabled: editing,
|
||||
});
|
||||
const form = draft.values;
|
||||
const setForm = draft.setValues;
|
||||
const pendingNew = hasDraftFor(useUserDrafts(), `catalog-${catalogKey}-new`, null);
|
||||
useReopenDraft({ newFormId: `catalog-${catalogKey}-new`, editFormId: `catalog-${catalogKey}-edit`, items, openNew: () => startNew(), openEdit: (i) => startEdit(i) });
|
||||
|
||||
function startNew() {
|
||||
setSelected(null);
|
||||
setForm(emptyForm(fields));
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function startEdit(item) {
|
||||
setSelected(item);
|
||||
const next = {};
|
||||
for (const f of fields) next[f.key] = item[f.key];
|
||||
setForm(next);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await runAction(async () => {
|
||||
const run = () => runAction(async () => {
|
||||
if (selected) await updateCatalogItem(catalogKey, selected.id, form);
|
||||
else await createCatalogItem(catalogKey, form);
|
||||
draft.clear();
|
||||
await reload();
|
||||
setEditing(false);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
if (selected) await draft.guardSave(run); else await run();
|
||||
}
|
||||
|
||||
function confirmDelete(item) {
|
||||
@@ -60,6 +72,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
|
||||
{ label: t('common_cancel'), variant: 'standard' },
|
||||
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => {
|
||||
await deleteCatalogItem(catalogKey, item.id);
|
||||
draft.discard();
|
||||
await reload();
|
||||
setEditing(false);
|
||||
}) },
|
||||
@@ -81,7 +94,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
|
||||
<div>
|
||||
<div className="toolbar">
|
||||
<h4 style={{ margin: 0 }}>{title}</h4>
|
||||
<Button size="sm" variant="accent" icon={Plus} onClick={startNew}>{t('common_new')}</Button>
|
||||
<Button size="sm" variant="accent" icon={Plus} onClick={startNew}>{t('common_new')}{pendingNew && !editing && <DraftMark />}</Button>
|
||||
</div>
|
||||
<ListView
|
||||
columns={columns}
|
||||
@@ -94,10 +107,11 @@ export function CatalogEditor({ catalogKey, fields, title }) {
|
||||
]}
|
||||
/>
|
||||
|
||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('common_new')} onClose={() => setEditing(false)} width={480} height={380}>
|
||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('common_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={480} height={420}>
|
||||
<DraftNotice draft={draft} />
|
||||
<FieldRow>
|
||||
{fields.map((f) => (
|
||||
<Field key={f.key} label={f.label}>
|
||||
<Field key={f.key} label={f.label} dirty={draft.isFieldDirty(f.key)}>
|
||||
{f.type === 'color' ? (
|
||||
<ComboBox options={COLOR_OPTIONS} value={form[f.key]} onChange={(v) => setForm((s) => ({ ...s, [f.key]: v }))} />
|
||||
) : f.type === 'checkbox' ? (
|
||||
@@ -118,7 +132,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
|
||||
</FieldRow>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button size="sm" variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
<Button size="sm" variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
||||
<Button size="sm" variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
</div>
|
||||
|
||||
@@ -6,21 +6,25 @@ import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { getCompanyInfo, updateCompanyInfo } from '../../services/company.js';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||
|
||||
export default function CompanySettingsPanel() {
|
||||
const { t, pushToast } = useUi();
|
||||
const branding = useBranding();
|
||||
const runAction = useAction();
|
||||
const { data: company, reload } = useAsync(getCompanyInfo, []);
|
||||
const [name, setName] = useState('');
|
||||
// Borrador del nombre. El logo (archivo) no se puede guardar como borrador: se vuelve a elegir.
|
||||
const draft = useDraftForm({
|
||||
formId: 'company-settings', entityId: 'system', initial: { name: company?.name || '', editVersion: company?.edit_version },
|
||||
label: t('config_company'), enabled: !!company,
|
||||
});
|
||||
const name = draft.values.name;
|
||||
const setName = (v) => draft.setField('name', v);
|
||||
const [logoFile, setLogoFile] = useState(null);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (company) setName(company.name);
|
||||
}, [company]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logoFile) { setPreview(null); return; }
|
||||
const url = URL.createObjectURL(logoFile);
|
||||
@@ -29,14 +33,15 @@ export default function CompanySettingsPanel() {
|
||||
}, [logoFile]);
|
||||
|
||||
async function save() {
|
||||
await runAction(async () => {
|
||||
await updateCompanyInfo({ name, logoFile });
|
||||
await draft.guardSave(() => runAction(async () => {
|
||||
await updateCompanyInfo({ name, logoFile, editVersion: draft.values.editVersion });
|
||||
draft.clear();
|
||||
setLogoFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
await reload();
|
||||
await branding.reload();
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
}), { fetchCurrent: async () => { const c = await getCompanyInfo(); return { name: c.name || '', editVersion: c.edit_version }; } });
|
||||
}
|
||||
|
||||
if (!company) return <div className="text-muted">{t('common_loading')}</div>;
|
||||
@@ -45,7 +50,8 @@ export default function CompanySettingsPanel() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Field label={t('company_name')}>
|
||||
<DraftNotice draft={draft} />
|
||||
<Field label={t('company_name')} dirty={draft.isFieldDirty('name')}>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="LinkedCorp" />
|
||||
</Field>
|
||||
<Field label={t('company_logo')} hint={t('company_logo_hint')}>
|
||||
@@ -56,7 +62,7 @@ export default function CompanySettingsPanel() {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => setLogoFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function ConfiguracionPage() {
|
||||
<div>
|
||||
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('config_title')}</h2>
|
||||
|
||||
<Expander icon={Building2} title={t('config_company')} defaultOpen>
|
||||
<Expander icon={Building2} title={t('config_company')} defaultOpen draftForms={['company-settings']}>
|
||||
<CompanySettingsPanel />
|
||||
</Expander>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function ConfiguracionPage() {
|
||||
</FieldRow>
|
||||
</Expander>
|
||||
|
||||
<Expander icon={Flag} title={t('config_priorities')}>
|
||||
<Expander icon={Flag} title={t('config_priorities')} draftForms={['catalog-priorities-']}>
|
||||
<CatalogEditor
|
||||
catalogKey="priorities"
|
||||
title={t('config_priorities')}
|
||||
@@ -81,7 +81,7 @@ export default function ConfiguracionPage() {
|
||||
/>
|
||||
</Expander>
|
||||
|
||||
<Expander icon={ListChecks} title={t('config_project_statuses')}>
|
||||
<Expander icon={ListChecks} title={t('config_project_statuses')} draftForms={['catalog-project-statuses-']}>
|
||||
<CatalogEditor
|
||||
catalogKey="project-statuses"
|
||||
title={t('config_project_statuses')}
|
||||
@@ -94,7 +94,7 @@ export default function ConfiguracionPage() {
|
||||
/>
|
||||
</Expander>
|
||||
|
||||
<Expander icon={Layers} title={t('config_activity_statuses')}>
|
||||
<Expander icon={Layers} title={t('config_activity_statuses')} draftForms={['catalog-activity-statuses-']}>
|
||||
<CatalogEditor
|
||||
catalogKey="activity-statuses"
|
||||
title={t('config_activity_statuses')}
|
||||
@@ -107,7 +107,7 @@ export default function ConfiguracionPage() {
|
||||
/>
|
||||
</Expander>
|
||||
|
||||
<Expander icon={FolderKanban} title={t('config_project_types')}>
|
||||
<Expander icon={FolderKanban} title={t('config_project_types')} draftForms={['catalog-project-types-']}>
|
||||
<CatalogEditor
|
||||
catalogKey="project-types"
|
||||
title={t('config_project_types')}
|
||||
@@ -115,7 +115,7 @@ export default function ConfiguracionPage() {
|
||||
/>
|
||||
</Expander>
|
||||
|
||||
<Expander icon={Mail} title={t('config_smtp')}>
|
||||
<Expander icon={Mail} title={t('config_smtp')} draftForms={['smtp-config']}>
|
||||
<SmtpSettingsPanel />
|
||||
</Expander>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Save, Send } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
@@ -7,27 +7,32 @@ import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||
|
||||
export default function SmtpSettingsPanel() {
|
||||
const { t, pushToast } = useUi();
|
||||
const runAction = useAction();
|
||||
const { data: config, reload } = useAsync(getSmtpConfig, []);
|
||||
const [form, setForm] = useState({ host: '', port: 587, user: '', from: '', pass: '' });
|
||||
// Borrador de la configuracion SMTP; la clave (pass) nunca se guarda como borrador.
|
||||
const draft = useDraftForm({
|
||||
formId: 'smtp-config', entityId: 'system',
|
||||
initial: config ? { host: config.host, port: config.port, user: config.user, from: config.from, pass: '', editVersion: config.edit_version } : { host: '', port: 587, user: '', from: '', pass: '' },
|
||||
label: t('config_smtp'), enabled: !!config, exclude: ['pass'],
|
||||
});
|
||||
const form = draft.values;
|
||||
const setForm = draft.setValues;
|
||||
const [testTo, setTestTo] = useState('');
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null); // { ok: bool, message: string }
|
||||
|
||||
useEffect(() => {
|
||||
if (config) setForm({ host: config.host, port: config.port, user: config.user, from: config.from, pass: '' });
|
||||
}, [config]);
|
||||
|
||||
async function save() {
|
||||
await runAction(async () => {
|
||||
await draft.guardSave(() => runAction(async () => {
|
||||
await updateSmtpConfig(form);
|
||||
draft.clear();
|
||||
await reload();
|
||||
setForm((f) => ({ ...f, pass: '' }));
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
}), { fetchCurrent: async () => { const c = await getSmtpConfig(); return { host: c.host, port: c.port, user: c.user, from: c.from, pass: '', editVersion: c.edit_version }; } });
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
@@ -50,23 +55,24 @@ export default function SmtpSettingsPanel() {
|
||||
return (
|
||||
<div>
|
||||
{!config.configured && <InfoBar tone="warning" dismissible={false}>{t('smtp_not_configured')}</InfoBar>}
|
||||
<DraftNotice draft={draft} />
|
||||
<FieldRow>
|
||||
<Field label={t('smtp_host')}>
|
||||
<Field label={t('smtp_host')} dirty={draft.isFieldDirty('host')}>
|
||||
<input className="input" value={form.host} onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))} placeholder="192.168.222.10" />
|
||||
</Field>
|
||||
<Field label={t('smtp_port')}>
|
||||
<Field label={t('smtp_port')} dirty={draft.isFieldDirty('port')}>
|
||||
<input className="input" type="number" value={form.port} onChange={(e) => setForm((f) => ({ ...f, port: Number(e.target.value) }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('smtp_user')}>
|
||||
<Field label={t('smtp_user')} dirty={draft.isFieldDirty('user')}>
|
||||
<input className="input" value={form.user} onChange={(e) => setForm((f) => ({ ...f, user: e.target.value }))} placeholder="project@linkedcorp.net" />
|
||||
</Field>
|
||||
<Field label={t('smtp_from')}>
|
||||
<Field label={t('smtp_from')} dirty={draft.isFieldDirty('from')}>
|
||||
<input className="input" value={form.from} onChange={(e) => setForm((f) => ({ ...f, from: e.target.value }))} placeholder="project@linkedcorp.net" />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<Field label={t('smtp_pass')} hint={config.configured ? t('smtp_pass_configured_hint') : undefined}>
|
||||
<Field label={t('smtp_pass')} dirty={draft.isFieldDirty('pass')} hint={config.configured ? t('smtp_pass_configured_hint') : undefined}>
|
||||
<PasswordInput value={form.pass} onChange={(e) => setForm((f) => ({ ...f, pass: e.target.value }))} />
|
||||
</Field>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Pencil, Save, Plus, Trash2, Power, Copy, Download } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
@@ -15,6 +16,8 @@ import { Button } from '../../components/Button.jsx';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { useDraftForm, useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
import { Tabs } from '../../components/Tabs.jsx';
|
||||
@@ -53,56 +56,56 @@ function SimpleTable({ columns, rows, empty }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceDetail({ id, onClose, onChanged, canEdit }) {
|
||||
function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabChange }) {
|
||||
const { t, pushToast } = useUi();
|
||||
const runAction = useAction();
|
||||
const { data: device, reload } = useAsync(() => getDevice(id), [id]);
|
||||
const { data: users } = useAsync(listUsers, []);
|
||||
const { data: departments } = useAsync(listDepartments, []);
|
||||
const [tab, setTab] = useState('general');
|
||||
const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
|
||||
const [searches, setSearches] = useState({});
|
||||
const [showComponents, setShowComponents] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState({});
|
||||
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
|
||||
const draft = useDraftForm({
|
||||
formId: 'device-edit', entityId: id,
|
||||
initial: device ? { status: device.status, assignedUserId: device.assigned_user_id || '', departmentId: device.department_id || '', notes: device.notes || '', editVersion: device.edit_version } : {},
|
||||
label: `${t('eq_detail')}: ${device?.hostname || id}`, enabled: !!device && canEdit && tab === 'general', onDiscard: () => setEditing(false),
|
||||
});
|
||||
const form = draft.values;
|
||||
const setForm = draft.setValues;
|
||||
useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]);
|
||||
|
||||
const flat = (list) => (list || []).flatMap((d) => [d, ...(d.children ? flat(d.children) : [])]);
|
||||
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
|
||||
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
|
||||
|
||||
function startEdit() {
|
||||
setForm({
|
||||
status: device.status,
|
||||
assignedUserId: device.assigned_user_id || '',
|
||||
departmentId: device.department_id || '',
|
||||
notes: device.notes || '',
|
||||
});
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function save() {
|
||||
const changed = form.status !== device.status
|
||||
|| Number(form.assignedUserId || 0) !== Number(device.assigned_user_id || 0)
|
||||
|| Number(form.departmentId || 0) !== Number(device.department_id || 0)
|
||||
|| form.notes !== (device.notes || '');
|
||||
if (!changed) { setEditing(false); return; }
|
||||
runAction(async () => {
|
||||
if (!draft.isDirty) { draft.discard(); setEditing(false); return; }
|
||||
draft.guardSave(() => runAction(async () => {
|
||||
await updateDevice(id, {
|
||||
status: form.status,
|
||||
assignedUserId: form.assignedUserId ? Number(form.assignedUserId) : null,
|
||||
departmentId: form.departmentId ? Number(form.departmentId) : null,
|
||||
notes: form.notes,
|
||||
editVersion: form.editVersion,
|
||||
});
|
||||
draft.clear();
|
||||
setEditing(false);
|
||||
await reload();
|
||||
onChanged();
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
}), { fetchCurrent: async () => { const d = await getDevice(id); return { status: d.status, assignedUserId: d.assigned_user_id || '', departmentId: d.department_id || '', notes: d.notes || '' }; } });
|
||||
}
|
||||
|
||||
if (!device) return <div className="text-muted">{t('common_loading')}</div>;
|
||||
|
||||
const tabs = [
|
||||
{ key: 'general', label: t('eq_tab_general') },
|
||||
{ key: 'general', label: t('eq_tab_general'), dirty: editing && draft.isDirty },
|
||||
{ key: 'users', label: t('eq_tab_users'), count: device.accounts.filter((a) => a.kind === 'local_user').length },
|
||||
{ key: 'hardware', label: t('eq_tab_hardware') },
|
||||
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
|
||||
@@ -191,26 +194,27 @@ function DeviceDetail({ id, onClose, onChanged, canEdit }) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t('common_status')}>
|
||||
<DraftNotice draft={draft} />
|
||||
<Field label={t('common_status')} dirty={draft.isFieldDirty('status')}>
|
||||
<ComboBox
|
||||
options={[{ value: 'active', label: t('eq_status_active') }, { value: 'retired', label: t('eq_status_retired') }]}
|
||||
value={form.status} onChange={(v) => setForm({ ...form, status: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('eq_assigned_to')}>
|
||||
<Field label={t('eq_assigned_to')} dirty={draft.isFieldDirty('assignedUserId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.assignedUserId} onChange={(v) => setForm({ ...form, assignedUserId: v })} />
|
||||
</Field>
|
||||
<Field label={t('common_department')}>
|
||||
<Field label={t('common_department')} dirty={draft.isFieldDirty('departmentId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm({ ...form, departmentId: v })} />
|
||||
</Field>
|
||||
<Row label={t('eq_first_seen')}>{fmtDate(device.first_seen)}</Row>
|
||||
<Row label={t('eq_last_seen')}>{fmtDate(device.last_seen)}</Row>
|
||||
<Field label={t('eq_notes')}>
|
||||
<Field label={t('eq_notes')} dirty={draft.isFieldDirty('notes')}>
|
||||
<textarea className="textarea" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
</Field>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -315,8 +319,28 @@ function DevicesTab({ canEdit }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [kind, setKind] = useState('');
|
||||
const [status, setStatus] = useState('active');
|
||||
const [selected, setSelected] = useState(null); // { id, view: 'info' }
|
||||
// Equipo abierto y su pestaña en la URL (?device=&deviceTab=): F5, retorno tras el login y
|
||||
// aviso de cambios sin guardar al cerrar el popup.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const deviceId = searchParams.get('device') ? Number(searchParams.get('device')) : null;
|
||||
const deviceTab = searchParams.get('deviceTab') || 'general';
|
||||
const setDeviceParams = (id, tab) => setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (id) { next.set('device', id); if (tab && tab !== 'general') next.set('deviceTab', tab); else next.delete('deviceTab'); }
|
||||
else { next.delete('device'); next.delete('deviceTab'); }
|
||||
return next;
|
||||
}, { replace: true });
|
||||
const selected = deviceId ? { id: deviceId, view: 'info' } : null;
|
||||
const setSelected = (v) => setDeviceParams(v?.id || null);
|
||||
const { data: devices, reload } = useAsync(() => listDevices({ kind, status }), [kind, status]);
|
||||
// Al volver con un borrador de un equipo (que el usuario sigue viendo), se reabre su popup.
|
||||
const drafts = useUserDrafts();
|
||||
useEffect(() => {
|
||||
if (!devices || deviceId) return;
|
||||
const d = drafts.find((x) => x.formId === 'device-edit' && devices.some((dv) => String(dv.id) === String(x.entityId)));
|
||||
if (d) setDeviceParams(Number(d.entityId));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [devices]);
|
||||
|
||||
const kindOptions = [{ value: '', label: t('common_all') }, ...KINDS.map((k) => ({ value: k, label: t(`eq_kind_${k}`) }))];
|
||||
const statusOptions = [
|
||||
@@ -352,7 +376,12 @@ function DevicesTab({ canEdit }) {
|
||||
/>
|
||||
)}
|
||||
<DetailPopup open={selected?.view === 'info'} title={t('eq_detail')} onClose={() => setSelected(null)} width={860} height={680}>
|
||||
{selected?.view === 'info' && <DeviceDetail key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} canEdit={canEdit} />}
|
||||
{selected?.view === 'info' && (
|
||||
<DeviceDetail
|
||||
key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} canEdit={canEdit}
|
||||
tab={deviceTab} onTabChange={(k) => setDeviceParams(selected.id, k)}
|
||||
/>
|
||||
)}
|
||||
</DetailPopup>
|
||||
</>
|
||||
);
|
||||
@@ -566,12 +595,16 @@ function AgentsTab() {
|
||||
export default function EquiposPage() {
|
||||
const { t } = useUi();
|
||||
const { data: access } = useAsync(getDevicesAccess, []);
|
||||
const [tab, setTab] = useState('summary');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') || 'summary';
|
||||
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
||||
const drafts = useUserDrafts();
|
||||
const devicesDirty = drafts.some((d) => d.formId === 'device-edit');
|
||||
if (!access) return <div className="text-muted">{t('common_loading')}</div>;
|
||||
if (!access.canView) return <Navigate to="/" replace />;
|
||||
const tabs = [
|
||||
{ key: 'summary', label: t('eq_tab_summary') },
|
||||
{ key: 'devices', label: t('eq_tab_devices') },
|
||||
{ key: 'devices', label: t('eq_tab_devices'), dirty: devicesDirty },
|
||||
{ key: 'software', label: t('eq_tab_software_all') },
|
||||
...(access.canManage ? [{ key: 'agents', label: t('eq_tab_agents') }, { key: 'share', label: t('eq_tab_share') }] : []),
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
@@ -7,6 +7,8 @@ import { useBranding } from '../../context/branding-context.jsx';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||
import { readSessionExpiredFlag } from '../../utils/session.js';
|
||||
import logoDark from '../../assets/logoLinkedCorpTxt.png';
|
||||
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
import logoLinkedCorpIcon from '../../assets/logoLinkedTrackIcon.png';
|
||||
@@ -16,6 +18,9 @@ export default function LoginPage() {
|
||||
const { t, theme } = useUi();
|
||||
const branding = useBranding();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
// Aviso de sesion vencida (se lee una sola vez al montar el login).
|
||||
const [sessionExpired] = useState(() => readSessionExpiredFlag());
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -26,8 +31,13 @@ export default function LoginPage() {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/', { replace: true });
|
||||
// Vuelve a donde estaba: ruta guardada al vencer la sesion (solo si es el mismo usuario) o
|
||||
// la ruta protegida que intentaba abrir; si no, al inicio.
|
||||
const { returnTo } = await login(email, password);
|
||||
// Tras un vencimiento solo vale returnTo (verificado contra el usuario que entra): la ruta
|
||||
// "from" de la redireccion podria ser la de OTRO usuario que uso este navegador.
|
||||
const from = sessionExpired ? null : location.state?.from;
|
||||
navigate(returnTo || (typeof from === 'string' && from.startsWith('/') ? from : '/'), { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message || t('login_error'));
|
||||
} finally {
|
||||
@@ -42,6 +52,9 @@ export default function LoginPage() {
|
||||
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('login_title')}</div>
|
||||
{sessionExpired && !error && (
|
||||
<InfoBar tone="warning" dismissible={false}>{t('session_expired_login_msg')}</InfoBar>
|
||||
)}
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<Field label={t('login_email')}>
|
||||
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
|
||||
|
||||
@@ -11,6 +11,9 @@ import { Button } from '../../components/Button.jsx';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||
|
||||
function formatDateTime(value) {
|
||||
@@ -25,7 +28,22 @@ export default function NotasPage() {
|
||||
|
||||
const [selected, setSelected] = useState(null); // nota abierta en el popup (null = cerrado)
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [content, setContent] = useState('');
|
||||
// Borrador de la nota (nueva o edicion de una propia; las compartidas conmigo son solo lectura).
|
||||
const editable = creating || !!selected?.is_owner;
|
||||
const draft = useDraftForm({
|
||||
formId: creating ? 'note-new' : 'note-edit', entityId: creating ? null : selected?.id,
|
||||
initial: { content: creating ? '' : (selected?.content || ''), editVersion: selected?.edit_version },
|
||||
label: creating ? t('notes_new') : `${t('notes_title')}: ${(selected?.content || '').slice(0, 40)}`,
|
||||
enabled: editable,
|
||||
});
|
||||
const content = draft.values.content;
|
||||
const setContent = (v) => draft.setField('content', v);
|
||||
const guardedClose = useGuardedClose();
|
||||
const drafts = useUserDrafts();
|
||||
useReopenDraft({
|
||||
newFormId: 'note-new', editFormId: 'note-edit', items: notes?.filter((n) => n.is_owner),
|
||||
openNew: () => openCreate(), openEdit: (n) => openNote(n),
|
||||
});
|
||||
const [shares, setShares] = useState([]);
|
||||
const [sharingWith, setSharingWith] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -33,7 +51,6 @@ export default function NotasPage() {
|
||||
function openCreate() {
|
||||
setSelected(null);
|
||||
setCreating(true);
|
||||
setContent('');
|
||||
setShares([]);
|
||||
setSharingWith('');
|
||||
}
|
||||
@@ -41,7 +58,6 @@ export default function NotasPage() {
|
||||
async function openNote(note) {
|
||||
setSelected(note);
|
||||
setCreating(false);
|
||||
setContent(note.content);
|
||||
setSharingWith('');
|
||||
setShares(note.is_owner ? await listNoteShares(note.id) : []);
|
||||
}
|
||||
@@ -53,15 +69,17 @@ export default function NotasPage() {
|
||||
|
||||
async function save() {
|
||||
if (!content.trim()) return;
|
||||
await runAction(async () => {
|
||||
const run = () => runAction(async () => {
|
||||
if (creating) {
|
||||
await createNote(content);
|
||||
} else {
|
||||
await updateNote(selected.id, content);
|
||||
await updateNote(selected.id, content, draft.values.editVersion);
|
||||
}
|
||||
draft.clear();
|
||||
closePopup();
|
||||
await reload();
|
||||
});
|
||||
if (creating) await run(); else await draft.guardSave(run);
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
@@ -75,6 +93,7 @@ export default function NotasPage() {
|
||||
label: t('common_delete'), variant: 'danger',
|
||||
onClick: () => runAction(async () => {
|
||||
await deleteNote(selected.id);
|
||||
draft.discard();
|
||||
closePopup();
|
||||
await reload();
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
@@ -129,7 +148,7 @@ export default function NotasPage() {
|
||||
<div className="notes-grid">
|
||||
<button type="button" className="notes-card notes-card-new" onClick={openCreate}>
|
||||
<Plus size={28} />
|
||||
<span>{t('notes_new')}</span>
|
||||
<span>{t('notes_new')}{hasDraftFor(drafts, 'note-new', null) && <DraftMark />}</span>
|
||||
</button>
|
||||
{displayedOwnNotes.map((note) => (
|
||||
<div
|
||||
@@ -139,6 +158,7 @@ export default function NotasPage() {
|
||||
{...(dragEnabled ? getRowProps(note) : {})}
|
||||
>
|
||||
<div className="notes-card-corner">
|
||||
{hasDraftFor(drafts, 'note-edit', note.id) && <DraftMark />}
|
||||
{note.share_count > 0 && (
|
||||
<span className="notes-card-corner-share" title={t('notes_share_title')}>
|
||||
<Share2 size={13} strokeWidth={2.5} />
|
||||
@@ -189,21 +209,23 @@ export default function NotasPage() {
|
||||
<DetailPopup
|
||||
open={creating || !!selected}
|
||||
title={creating ? t('notes_new') : t('notes_title')}
|
||||
onClose={closePopup}
|
||||
onClose={() => guardedClose(draft, closePopup)}
|
||||
width={560}
|
||||
height={creating || selected?.is_owner ? 620 : 420}
|
||||
>
|
||||
{creating || selected?.is_owner ? (
|
||||
<>
|
||||
<DraftNotice draft={draft} />
|
||||
<textarea
|
||||
className="textarea" rows={6} autoFocus
|
||||
className={`textarea ${draft.isDirty ? 'input-dirty' : ''}`} rows={6} autoFocus
|
||||
aria-label={draft.isDirty ? `${t('notes_title')} (${t('draft_field_modified')})` : t('notes_title')}
|
||||
placeholder={t('notes_placeholder')}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: creating ? 0 : 20 }}>
|
||||
<Button variant="accent" icon={Save} onClick={save} disabled={!content.trim()}>{t('common_save')}</Button>
|
||||
<Button variant="standard" onClick={closePopup}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" onClick={() => { draft.discard(); closePopup(); }}>{t('common_cancel')}</Button>
|
||||
{!creating && <Button variant="danger" icon={Trash2} onClick={confirmDelete}>{t('common_delete')}</Button>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import { Button } from '../../components/Button.jsx';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||
|
||||
function flatten(nodes, acc = []) {
|
||||
for (const n of nodes) {
|
||||
@@ -23,32 +26,42 @@ export default function DepartmentsPanel() {
|
||||
const { data: tree, reload } = useAsync(listDepartments, []);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', parentId: '' });
|
||||
const guardedClose = useGuardedClose();
|
||||
// Borrador del departamento (nuevo o edicion).
|
||||
const draft = useDraftForm({
|
||||
formId: selected ? 'department-edit' : 'department-new', entityId: selected?.id ?? null,
|
||||
initial: selected ? { name: selected.name, parentId: selected.parent_id || '', editVersion: selected.edit_version } : { name: '', parentId: '' },
|
||||
label: selected ? `${t('common_edit')}: ${selected.name}` : t('department_new'), enabled: editing,
|
||||
});
|
||||
const form = draft.values;
|
||||
const setForm = draft.setValues;
|
||||
|
||||
const flatOptions = tree ? flatten(tree).map((d) => ({ value: d.id, label: d.name })) : [];
|
||||
const pendingNew = hasDraftFor(useUserDrafts(), 'department-new', null);
|
||||
useReopenDraft({ newFormId: 'department-new', editFormId: 'department-edit', items: tree ? flatten(tree) : null, openNew: () => startNew(), openEdit: (n) => startEdit(n) });
|
||||
|
||||
function startNew() {
|
||||
setSelected(null);
|
||||
setForm({ name: '', parentId: '' });
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function startEdit(node) {
|
||||
setSelected(node);
|
||||
setForm({ name: node.name, parentId: node.parent_id || '' });
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim()) return;
|
||||
await runAction(async () => {
|
||||
const payload = { name: form.name.trim(), parentId: form.parentId || null };
|
||||
const run = () => runAction(async () => {
|
||||
const payload = { name: form.name.trim(), parentId: form.parentId || null, editVersion: form.editVersion };
|
||||
if (selected) await updateDepartment(selected.id, payload);
|
||||
else await createDepartment(payload);
|
||||
draft.clear();
|
||||
await reload();
|
||||
setEditing(false);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
if (selected) await draft.guardSave(run); else await run();
|
||||
}
|
||||
|
||||
function confirmDelete(node) {
|
||||
@@ -62,6 +75,7 @@ export default function DepartmentsPanel() {
|
||||
label: t('common_delete'), variant: 'danger',
|
||||
onClick: () => runAction(async () => {
|
||||
await deleteDepartment(node.id);
|
||||
draft.discard();
|
||||
await reload();
|
||||
setEditing(false);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
@@ -78,16 +92,17 @@ export default function DepartmentsPanel() {
|
||||
<div className="panel-card">
|
||||
<div className="toolbar">
|
||||
<h3 style={{ margin: 0 }}>{t('org_departments')}</h3>
|
||||
<Button variant="accent" icon={Plus} onClick={startNew}>{t('department_new')}</Button>
|
||||
<Button variant="accent" icon={Plus} onClick={startNew}>{t('department_new')}{pendingNew && !editing && <DraftMark />}</Button>
|
||||
</div>
|
||||
<Treeview nodes={tree} onSelect={startEdit} selectedId={selected?.id} />
|
||||
</div>
|
||||
|
||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('department_new')} onClose={() => setEditing(false)} width={480} height={360}>
|
||||
<Field label={t('common_name')}>
|
||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('department_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={480} height={400}>
|
||||
<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('department_parent')}>
|
||||
<Field label={t('department_parent')} dirty={draft.isFieldDirty('parentId')}>
|
||||
<ComboBox
|
||||
options={[{ value: '', label: '—' }, ...flatOptions.filter((o) => o.value !== selected?.id)]}
|
||||
value={form.parentId}
|
||||
@@ -96,7 +111,7 @@ export default function DepartmentsPanel() {
|
||||
</Field>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||
{selected && <Button variant="danger" icon={Trash2} onClick={() => confirmDelete(selected)}>{t('common_delete')}</Button>}
|
||||
</div>
|
||||
</DetailPopup>
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { Tabs } from '../../components/Tabs.jsx';
|
||||
import { useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||
import DepartmentsPanel from './DepartmentsPanel.jsx';
|
||||
import UsersPanel from './UsersPanel.jsx';
|
||||
|
||||
export default function OrganizacionPage() {
|
||||
const { t } = useUi();
|
||||
const [tab, setTab] = useState('departments');
|
||||
// Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') === 'users' ? 'users' : 'departments';
|
||||
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
||||
const drafts = useUserDrafts();
|
||||
const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('nav_organization')}</h2>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ key: 'departments', label: t('org_departments') },
|
||||
{ key: 'users', label: t('org_users') },
|
||||
{ key: 'departments', label: t('org_departments'), dirty: dirty('department-') },
|
||||
{ key: 'users', label: t('org_users'), dirty: dirty('user-') },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
|
||||
@@ -12,6 +12,9 @@ import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||
|
||||
function flatten(nodes, acc = []) {
|
||||
@@ -35,39 +38,59 @@ export default function UsersPanel() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [approving, setApproving] = useState(null);
|
||||
const [approveForm, setApproveForm] = useState({ roleId: '', departmentId: '' });
|
||||
const guardedClose = useGuardedClose();
|
||||
// Borrador del usuario (nuevo o edicion). La contraseña nunca se guarda como borrador.
|
||||
const draft = useDraftForm({
|
||||
formId: selected ? 'user-edit' : 'user-new', entityId: selected?.id ?? null,
|
||||
initial: selected
|
||||
? { name: selected.name, email: selected.email, roleId: selected.role_id, departmentId: selected.department_id || '', position: selected.position || '', password: '', editVersion: selected.edit_version }
|
||||
: EMPTY_FORM,
|
||||
label: selected ? `${t('common_edit')}: ${selected.name}` : t('user_new'), enabled: editing, exclude: ['password'],
|
||||
});
|
||||
const form = draft.values;
|
||||
const setForm = draft.setValues;
|
||||
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
|
||||
const approveDraft = useDraftForm({
|
||||
formId: 'user-approve', entityId: approving?.id,
|
||||
initial: { roleId: approving?.role_id || collaboratorRole?.id || '', departmentId: '', editVersion: approving?.edit_version },
|
||||
label: `${t('user_approve_title')}: ${approving?.name || ''}`, enabled: !!approving,
|
||||
});
|
||||
const approveForm = approveDraft.values;
|
||||
const setApproveForm = approveDraft.setValues;
|
||||
const pendingNew = hasDraftFor(useUserDrafts(), 'user-new', null);
|
||||
useReopenDraft({ newFormId: canManage ? 'user-new' : null, editFormId: canManage ? 'user-edit' : null, items: users, openNew: () => startNew(), openEdit: (u) => startEdit(u) });
|
||||
useReopenDraft({ editFormId: canManage ? 'user-approve' : null, items: users, openEdit: (u) => setApproving(u) });
|
||||
|
||||
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
|
||||
const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name }));
|
||||
|
||||
function startNew() {
|
||||
setSelected(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function startEdit(user) {
|
||||
if (!canManage) return;
|
||||
setSelected(user);
|
||||
setForm({ name: user.name, email: user.email, roleId: user.role_id, departmentId: user.department_id || '', position: user.position || '', password: '' });
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim() || !form.email.trim() || !form.roleId) return;
|
||||
await runAction(async () => {
|
||||
if (!selected && !form.password) return;
|
||||
const run = () => runAction(async () => {
|
||||
if (selected) {
|
||||
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null });
|
||||
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, editVersion: form.editVersion });
|
||||
} else {
|
||||
if (!form.password) return;
|
||||
await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password });
|
||||
}
|
||||
draft.clear();
|
||||
await reload();
|
||||
setEditing(false);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
if (selected) await draft.guardSave(run); else await run();
|
||||
}
|
||||
|
||||
async function toggleActive(user) {
|
||||
@@ -78,18 +101,17 @@ export default function UsersPanel() {
|
||||
}
|
||||
|
||||
function startApprove(user) {
|
||||
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
|
||||
setApproving(user);
|
||||
setApproveForm({ roleId: user.role_id || collaboratorRole?.id || '', departmentId: '' });
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
await runAction(async () => {
|
||||
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null });
|
||||
await approveDraft.guardSave(() => runAction(async () => {
|
||||
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null, editVersion: approveForm.editVersion });
|
||||
approveDraft.clear();
|
||||
setApproving(null);
|
||||
await reload();
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
function resendEmail(user) {
|
||||
@@ -129,7 +151,7 @@ export default function UsersPanel() {
|
||||
<div className="toolbar-left">
|
||||
<SearchInput value={search} onChange={setSearch} />
|
||||
</div>
|
||||
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}</Button>}
|
||||
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}{pendingNew && !editing && <DraftMark />}</Button>}
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
@@ -164,51 +186,53 @@ export default function UsersPanel() {
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => setEditing(false)} width={560} height={440}>
|
||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
|
||||
<DraftNotice draft={draft} />
|
||||
<FieldRow>
|
||||
<Field label={t('common_name')}>
|
||||
<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('user_email')}>
|
||||
<Field label={t('user_email')} dirty={draft.isFieldDirty('email')}>
|
||||
<input className="input" type="email" value={form.email} disabled={!!selected} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('user_role')}>
|
||||
<Field label={t('user_role')} dirty={draft.isFieldDirty('roleId')}>
|
||||
<ComboBox options={roleOptions} value={form.roleId} onChange={(v) => setForm((f) => ({ ...f, roleId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('common_department')}>
|
||||
<Field label={t('common_department')} dirty={draft.isFieldDirty('departmentId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm((f) => ({ ...f, departmentId: v }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('user_position')}>
|
||||
<Field label={t('user_position')} dirty={draft.isFieldDirty('position')}>
|
||||
<input className="input" value={form.position} onChange={(e) => setForm((f) => ({ ...f, position: e.target.value }))} />
|
||||
</Field>
|
||||
{!selected && (
|
||||
<Field label={t('user_password')}>
|
||||
<Field label={t('user_password')} dirty={draft.isFieldDirty('password')}>
|
||||
<PasswordInput value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
|
||||
</Field>
|
||||
)}
|
||||
</FieldRow>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
|
||||
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => setApproving(null)} width={420} height={300}>
|
||||
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => guardedClose(approveDraft, () => setApproving(null))} width={420} height={340}>
|
||||
<DraftNotice draft={approveDraft} />
|
||||
<FieldRow>
|
||||
<Field label={t('user_role')}>
|
||||
<Field label={t('user_role')} dirty={approveDraft.isFieldDirty('roleId')}>
|
||||
<ComboBox options={roleOptions} value={approveForm.roleId} onChange={(v) => setApproveForm((f) => ({ ...f, roleId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('common_department')}>
|
||||
<Field label={t('common_department')} dirty={approveDraft.isFieldDirty('departmentId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={approveForm.departmentId} onChange={(v) => setApproveForm((f) => ({ ...f, departmentId: v }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="accent" icon={Check} onClick={confirmApprove}>{t('user_approve')}</Button>
|
||||
<Button variant="standard" onClick={() => setApproving(null)}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" onClick={() => { approveDraft.discard(); setApproving(null); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,10 @@ import { Tabs } from '../../components/Tabs.jsx';
|
||||
import { CommentsPanel } from '../../components/CommentsPanel.jsx';
|
||||
import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { useDraftForm, useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||
import { getActivity } from '../../services/activities.js';
|
||||
|
||||
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `${String(h).padStart(2, '0')}:00` }));
|
||||
|
||||
@@ -26,11 +30,11 @@ function toForm(a) {
|
||||
name: a.name, description: a.description || '', responsibleUserId: a.responsible_user_id || '',
|
||||
priorityId: a.priority_id, statusId: a.status_id, startDate: a.start_date || '', dueDate: a.due_date || '',
|
||||
actualEndDate: a.actual_end_date || '', isMilestone: !!a.is_milestone, estimatedHours: a.estimated_hours || '',
|
||||
progressPct: a.progress_pct,
|
||||
progressPct: a.progress_pct, editVersion: a.edit_version,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged, initialTab }) {
|
||||
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged, tab = 'detail', onTabChange }) {
|
||||
const { t, pushToast } = useUi();
|
||||
const runAction = useAction();
|
||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||
@@ -42,22 +46,75 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
await reloadTasks();
|
||||
});
|
||||
|
||||
const [tab, setTab] = useState(initialTab || 'detail');
|
||||
// La pestaña la controla el padre (vive en la URL: ?activityTab=).
|
||||
const setTab = onTabChange;
|
||||
const drafts = useUserDrafts();
|
||||
const guardedClose = useGuardedClose();
|
||||
// Se recargan al cambiar de pestaña, y ademas CommentsPanel/AttachmentsPanel avisan via
|
||||
// onChanged cuando se agrega/borra algo estando ya parado en esa pestaña (si no, el badge se
|
||||
// quedaba pegado en el numero viejo hasta cambiar de pestaña y volver).
|
||||
const { data: comments, reload: reloadComments } = useAsync(() => listComments('activity', activity.id), [activity.id, tab]);
|
||||
const { data: attachments, reload: reloadAttachments } = useAsync(() => listAttachments('activity', activity.id), [activity.id, tab]);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState(() => toForm(activity));
|
||||
const [newTask, setNewTask] = useState('');
|
||||
const [editingTaskId, setEditingTaskId] = useState(null);
|
||||
const [editingTaskName, setEditingTaskName] = useState('');
|
||||
const [addingDep, setAddingDep] = useState('');
|
||||
const [promoting, setPromoting] = useState(null);
|
||||
const [promoteForm, setPromoteForm] = useState(null);
|
||||
|
||||
useEffect(() => { setForm(toForm(activity)); setEditing(false); }, [activity]);
|
||||
// Borradores: edicion de la actividad, nueva tarea, edicion en linea de una tarea y
|
||||
// "Convertir en recurrente". Todos sobreviven a cambiar de pestaña/menu, F5 y vencimiento.
|
||||
// Cada borrador se activa solo mientras su pestaña esta visible: uno oculto queda "pendiente"
|
||||
// (marcado en su pestaña), no "en edicion".
|
||||
const draft = useDraftForm({ formId: 'activity-info', entityId: activity.id, initial: toForm(activity), label: `${activity.code} — ${t('tab_info')}`, enabled: tab === 'detail', onDiscard: () => setEditing(false) });
|
||||
const form = draft.values;
|
||||
const setForm = draft.setValues;
|
||||
useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]);
|
||||
|
||||
const newTaskDraft = useDraftForm({ formId: 'task-new', entityId: activity.id, initial: { name: '' }, label: `${activity.code} — ${t('task_new')}`, enabled: tab === 'tasks' });
|
||||
const newTask = newTaskDraft.values.name;
|
||||
const setNewTask = (v) => newTaskDraft.setField('name', v);
|
||||
|
||||
const editingTask = (tasks || []).find((task) => task.id === editingTaskId);
|
||||
const taskEditDraft = useDraftForm({
|
||||
formId: 'task-edit', entityId: editingTaskId, initial: { name: editingTask?.name || '', editVersion: editingTask?.edit_version },
|
||||
label: `${activity.code} — ${editingTask?.name || ''}`, enabled: !!editingTask && tab === 'tasks', onDiscard: () => setEditingTaskId(null),
|
||||
});
|
||||
const editingTaskName = taskEditDraft.values.name;
|
||||
const setEditingTaskName = (v) => taskEditDraft.setField('name', v);
|
||||
|
||||
const promoteDraft = useDraftForm({
|
||||
formId: 'task-promote', entityId: promoting?.id,
|
||||
initial: promoting ? {
|
||||
name: promoting.name, responsibleUserId: promoting.responsible_user_id || '',
|
||||
intervalUnit: 'months', intervalValue: 1, nextDueDate: promoting.due_date || '', endDate: '',
|
||||
reminderDaysBefore: 7, reminderHour: 9,
|
||||
} : {},
|
||||
label: `${promoting?.name || ''} — ${t('recurring_task_promote_title')}`, enabled: !!promoting, onDiscard: () => setPromoting(null),
|
||||
});
|
||||
const promoteForm = promoting ? promoteDraft.values : null;
|
||||
const setPromoteForm = promoteDraft.setValues;
|
||||
|
||||
// Al volver con borradores de edicion en linea / "Convertir en recurrente" de alguna tarea de
|
||||
// esta actividad, se reabren sobre su tarea.
|
||||
useEffect(() => {
|
||||
if (!tasks) return;
|
||||
const byId = (formId) => {
|
||||
const d = drafts.find((x) => x.formId === formId && tasks.some((task) => String(task.id) === String(x.entityId)));
|
||||
return d ? tasks.find((task) => String(task.id) === String(d.entityId)) : null;
|
||||
};
|
||||
const toEdit = byId('task-edit');
|
||||
if (toEdit && !editingTaskId) setEditingTaskId(toEdit.id);
|
||||
const toPromote = byId('task-promote');
|
||||
if (toPromote && !promoting) setPromoting(toPromote);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tasks]);
|
||||
|
||||
// Pestañas con borradores pendientes de esta actividad (por la URL que guarda cada borrador:
|
||||
// ?activity=<id>&activityTab=<pestaña>).
|
||||
const dirtyTab = (key) => drafts.some((d) => {
|
||||
if (typeof d.path !== 'string') return false;
|
||||
const q = new URLSearchParams(d.path.split('?')[1] || '');
|
||||
return q.get('activity') === String(activity.id) && (q.get('activityTab') || 'detail') === key;
|
||||
});
|
||||
|
||||
const hasTasks = tasks && tasks.length > 0;
|
||||
const totalTasksCount = (tasks || []).length;
|
||||
@@ -72,19 +129,21 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
.map((a) => ({ value: a.id, label: `${a.code} — ${a.name}` }));
|
||||
|
||||
async function save() {
|
||||
await runAction(async () => {
|
||||
if (!draft.isDirty) { draft.discard(); setEditing(false); return; }
|
||||
await draft.guardSave(() => runAction(async () => {
|
||||
await updateActivity(activity.id, { ...form, responsibleUserId: form.responsibleUserId || null, estimatedHours: form.estimatedHours || null });
|
||||
draft.clear();
|
||||
setEditing(false);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
onChanged();
|
||||
});
|
||||
}), { fetchCurrent: async () => toForm(await getActivity(activity.id)) });
|
||||
}
|
||||
|
||||
async function addTaskItem() {
|
||||
if (!newTask.trim()) return;
|
||||
await runAction(async () => {
|
||||
await createTask(activity.id, { name: newTask.trim() });
|
||||
setNewTask('');
|
||||
newTaskDraft.clear();
|
||||
await reloadTasks();
|
||||
onChanged();
|
||||
});
|
||||
@@ -100,31 +159,26 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
|
||||
function startEditTask(task) {
|
||||
setEditingTaskId(task.id);
|
||||
setEditingTaskName(task.name);
|
||||
}
|
||||
|
||||
function cancelEditTask() {
|
||||
taskEditDraft.discard();
|
||||
setEditingTaskId(null);
|
||||
setEditingTaskName('');
|
||||
}
|
||||
|
||||
async function saveEditTask() {
|
||||
if (!editingTaskName.trim()) return;
|
||||
await runAction(async () => {
|
||||
await updateTask(editingTaskId, { name: editingTaskName.trim() });
|
||||
cancelEditTask();
|
||||
await taskEditDraft.guardSave(() => runAction(async () => {
|
||||
await updateTask(editingTaskId, { name: editingTaskName.trim(), editVersion: taskEditDraft.values.editVersion });
|
||||
taskEditDraft.clear();
|
||||
setEditingTaskId(null);
|
||||
await reloadTasks();
|
||||
onChanged();
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
function startPromote(task) {
|
||||
setPromoting(task);
|
||||
setPromoteForm({
|
||||
name: task.name, responsibleUserId: task.responsible_user_id || '',
|
||||
intervalUnit: 'months', intervalValue: 1, nextDueDate: task.due_date || '', endDate: '',
|
||||
reminderDaysBefore: 7, reminderHour: 9,
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmPromote() {
|
||||
@@ -133,6 +187,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
await promoteTaskToRecurring(promoting.id, {
|
||||
...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, endDate: promoteForm.endDate || null,
|
||||
});
|
||||
promoteDraft.clear();
|
||||
setPromoting(null);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
@@ -151,11 +206,11 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
<div>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ key: 'detail', label: t('tab_info') },
|
||||
{ key: 'tasks', label: t('tasks_title'), count: tasksTabCount },
|
||||
{ key: 'detail', label: t('tab_info'), dirty: dirtyTab('detail') },
|
||||
{ key: 'tasks', label: t('tasks_title'), count: tasksTabCount, dirty: dirtyTab('tasks') },
|
||||
{ key: 'dependencies', label: t('activity_dependencies'), count: (activity.dependencies || []).length },
|
||||
{ key: 'comments', label: t('tab_comments'), count: (comments || []).length },
|
||||
{ key: 'documents', label: t('tab_documents'), count: (attachments || []).length },
|
||||
{ key: 'comments', label: t('tab_comments'), count: (comments || []).length, dirty: dirtyTab('comments') },
|
||||
{ key: 'documents', label: t('tab_documents'), count: (attachments || []).length, dirty: dirtyTab('documents') },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
@@ -167,7 +222,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
{editing ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
<Button variant="standard" icon={X} onClick={() => { setForm(toForm(activity)); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" icon={X} onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
|
||||
@@ -193,46 +248,47 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Field label={t('common_name')}>
|
||||
<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 }))} />
|
||||
</Field>
|
||||
<Field label={t('common_description')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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_actual_end_date')}>
|
||||
<Field label={t('activity_actual_end_date')} dirty={draft.isFieldDirty('actualEndDate')}>
|
||||
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: e.target.value }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('activity_estimated_hours')}>
|
||||
<Field label={t('activity_estimated_hours')} dirty={draft.isFieldDirty('estimatedHours')}>
|
||||
<input className="input" type="number" value={form.estimatedHours} onChange={(e) => setForm((f) => ({ ...f, estimatedHours: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label={t('activity_milestone')}>
|
||||
<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>
|
||||
{!hasTasks && (
|
||||
<Field label={t('activity_progress')}>
|
||||
<Field label={t('activity_progress')} dirty={draft.isFieldDirty('progressPct')}>
|
||||
<input className="input" type="number" min={0} max={100} value={form.progressPct} onChange={(e) => setForm((f) => ({ ...f, progressPct: e.target.value }))} />
|
||||
</Field>
|
||||
)}
|
||||
@@ -254,7 +310,8 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
{editingTaskId === task.id ? (
|
||||
<>
|
||||
<input
|
||||
className="input" style={{ flex: 1 }} value={editingTaskName} autoFocus
|
||||
className={`input ${taskEditDraft.isDirty ? 'input-dirty' : ''}`} style={{ flex: 1 }} value={editingTaskName} autoFocus
|
||||
aria-label={taskEditDraft.isDirty ? `${t('common_edit')} (${t('draft_field_modified')})` : t('common_edit')}
|
||||
onChange={(e) => setEditingTaskName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveEditTask(); if (e.key === 'Escape') cancelEditTask(); }}
|
||||
/>
|
||||
@@ -277,7 +334,11 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
))}
|
||||
{orderedTasks.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<input className="input" value={newTask} placeholder={t('task_new')} onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTaskItem()} />
|
||||
<input
|
||||
className={`input ${newTaskDraft.isDirty ? 'input-dirty' : ''}`} value={newTask} placeholder={t('task_new')}
|
||||
aria-label={newTaskDraft.isDirty ? `${t('task_new')} (${t('draft_field_modified')})` : t('task_new')}
|
||||
onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTaskItem()}
|
||||
/>
|
||||
<Button variant="standard" icon={Plus} onClick={addTaskItem}>{t('common_new')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,34 +373,35 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
<DetailPopup
|
||||
open={!!promoting}
|
||||
title={t('recurring_task_promote_title')}
|
||||
onClose={() => setPromoting(null)}
|
||||
onClose={() => guardedClose(promoteDraft, () => setPromoting(null))}
|
||||
width={560}
|
||||
height={600}
|
||||
height={640}
|
||||
>
|
||||
{promoteForm && (
|
||||
<>
|
||||
<DraftNotice draft={promoteDraft} />
|
||||
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 10 }}>{t('recurring_task_promote_hint')}</div>
|
||||
<Field label={t('common_name')}>
|
||||
<Field label={t('common_name')} dirty={promoteDraft.isFieldDirty('name')}>
|
||||
<input className="input" value={promoteForm.name} onChange={(e) => setPromoteForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
||||
</Field>
|
||||
<FieldRow>
|
||||
<Field label={t('common_responsible')}>
|
||||
<Field label={t('common_responsible')} dirty={promoteDraft.isFieldDirty('responsibleUserId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={promoteForm.responsibleUserId} onChange={(v) => setPromoteForm((f) => ({ ...f, responsibleUserId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('recurring_task_next_due')}>
|
||||
<Field label={t('recurring_task_next_due')} dirty={promoteDraft.isFieldDirty('nextDueDate')}>
|
||||
<input className="input" type="date" value={promoteForm.nextDueDate} onChange={(e) => setPromoteForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')}>
|
||||
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')} dirty={promoteDraft.isFieldDirty('endDate')}>
|
||||
<input className="input" type="date" value={promoteForm.endDate} onChange={(e) => setPromoteForm((f) => ({ ...f, endDate: e.target.value }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('recurring_task_every')}>
|
||||
<Field label={t('recurring_task_every')} dirty={promoteDraft.isFieldDirty('intervalValue')}>
|
||||
<input className="input" type="number" min={1} value={promoteForm.intervalValue} onChange={(e) => setPromoteForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} />
|
||||
</Field>
|
||||
<Field label={t('recurring_task_interval_unit')}>
|
||||
<Field label={t('recurring_task_interval_unit')} dirty={promoteDraft.isFieldDirty('intervalUnit')}>
|
||||
<ComboBox
|
||||
options={[
|
||||
{ value: 'days', label: t('recurring_task_unit_days') },
|
||||
@@ -352,16 +414,16 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('recurring_task_reminder_days')}>
|
||||
<Field label={t('recurring_task_reminder_days')} dirty={promoteDraft.isFieldDirty('reminderDaysBefore')}>
|
||||
<input className="input" type="number" min={0} value={promoteForm.reminderDaysBefore} onChange={(e) => setPromoteForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />
|
||||
</Field>
|
||||
<Field label={t('recurring_task_reminder_hour')}>
|
||||
<Field label={t('recurring_task_reminder_hour')} dirty={promoteDraft.isFieldDirty('reminderHour')}>
|
||||
<ComboBox options={HOUR_OPTIONS} value={promoteForm.reminderHour} onChange={(v) => setPromoteForm((f) => ({ ...f, reminderHour: Number(v) }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<Button variant="accent" icon={Repeat} onClick={confirmPromote}>{t('recurring_task_promote')}</Button>
|
||||
<Button variant="standard" onClick={() => setPromoting(null)}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" onClick={() => { promoteDraft.discard(); setPromoting(null); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Plus, Save, Trash2, ArrowRightLeft, FolderUp, Archive, RotateCcw } 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 { listProjects } from '../../services/projects.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';
|
||||
@@ -20,10 +20,13 @@ import { StatusBadge, ProgressBar, DueBadge } from '../../components/StatusBadge
|
||||
import { dueStatus } from '../../utils/dueStatus.js';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
|
||||
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, openActivityId, openActivityTab }) {
|
||||
export default function ProjectActivitiesTab({ project, projectId }) {
|
||||
const { t, pushToast, openDialog } = useUi();
|
||||
const { user: currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -45,17 +48,59 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
|
||||
const { data: users } = useAsync(listUsers, []);
|
||||
const { data: allProjects } = useAsync(() => listProjects(), []);
|
||||
const { data: allProjects } = useAsync(listProjectOptions, []);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [selectedInitialTab, setSelectedInitialTab] = useState(undefined);
|
||||
const [moving, setMoving] = useState(null);
|
||||
const [targetProjectId, setTargetProjectId] = useState('');
|
||||
const [converting, setConverting] = useState(null);
|
||||
const [convertForm, setConvertForm] = useState({ projectName: '', taskActivityName: '' });
|
||||
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.
|
||||
@@ -78,14 +123,14 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
})),
|
||||
] : null;
|
||||
|
||||
// Llegada desde el buscador global de Proyectos: abre directo la actividad indicada (una sola vez).
|
||||
const openedFromDeepLink = useRef(false);
|
||||
// Al volver con un borrador de "Convertir en proyecto", se reabre sobre su actividad.
|
||||
useEffect(() => {
|
||||
if (openedFromDeepLink.current || !openActivityId || !activities) return;
|
||||
openedFromDeepLink.current = true;
|
||||
openActivity({ id: openActivityId }, openActivityTab);
|
||||
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
|
||||
}, [openActivityId, activities]);
|
||||
}, [activities]);
|
||||
|
||||
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
||||
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
|
||||
@@ -98,17 +143,15 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
|
||||
await runAction(async () => {
|
||||
await createActivity(projectId, { ...form, responsibleUserId: form.responsibleUserId || null });
|
||||
draft.clear();
|
||||
setCreating(false);
|
||||
setForm(EMPTY_FORM);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
await reload();
|
||||
});
|
||||
}
|
||||
|
||||
async function openActivity(row, initialTab) {
|
||||
const full = await getActivity(row.id);
|
||||
setSelected(full);
|
||||
setSelectedInitialTab(initialTab);
|
||||
function openActivity(row, initialTab) {
|
||||
setActivityParams(row.id, initialTab);
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
@@ -151,7 +194,6 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
|
||||
function startConvert(row) {
|
||||
setConverting(row);
|
||||
setConvertForm({ projectName: row.name, taskActivityName: row.name });
|
||||
}
|
||||
|
||||
function confirmConvert() {
|
||||
@@ -171,6 +213,7 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
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}`);
|
||||
@@ -229,7 +272,7 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
{project?.isOwner && (
|
||||
<Button variant="standard" icon={Archive} onClick={() => setTrashOpen(true)}>{t('activities_trash')}</Button>
|
||||
)}
|
||||
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button>
|
||||
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}{pendingNew && !creating && <DraftMark />}</Button>
|
||||
</div>
|
||||
|
||||
<DetailPopup open={trashOpen} title={t('activities_trash')} onClose={() => setTrashOpen(false)} width={480} height={420}>
|
||||
@@ -271,17 +314,18 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
)}
|
||||
</DetailPopup>
|
||||
|
||||
<DetailPopup open={!!converting} title={t('activity_convert_title')} onClose={() => setConverting(null)} width={520} height={360}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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 }))}
|
||||
@@ -294,38 +338,39 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
>
|
||||
{t('activity_convert')}
|
||||
</Button>
|
||||
<Button variant="standard" onClick={() => setConverting(null)}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" onClick={() => { convertDraft.discard(); setConverting(null); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DetailPopup>
|
||||
|
||||
<DetailPopup open={creating} title={t('activity_new')} onClose={() => setCreating(false)} width={640} height={560}>
|
||||
<Field label={t('common_name')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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')}
|
||||
@@ -334,18 +379,20 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
</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>
|
||||
<Button variant="standard" onClick={() => { draft.discard(); setCreating(false); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
|
||||
<DetailPopup open={!!selected} title={selected ? `${selected.code} — ${selected.name}` : ''} onClose={() => setSelected(null)} width={720} height={680}>
|
||||
<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}
|
||||
initialTab={selectedInitialTab}
|
||||
tab={activityTab}
|
||||
onTabChange={(key) => setActivityParams(selected.id, key)}
|
||||
/>
|
||||
)}
|
||||
</DetailPopup>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Pencil, Save, X } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { updateProject } from '../../services/projects.js';
|
||||
import { updateProject, getProject } from '../../services/projects.js';
|
||||
import { listCatalog } from '../../services/catalogs.js';
|
||||
import { listDepartments } from '../../services/departments.js';
|
||||
import { listUsers } from '../../services/users.js';
|
||||
@@ -11,6 +11,8 @@ import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||
|
||||
function flatten(nodes, acc = []) {
|
||||
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
|
||||
@@ -21,6 +23,7 @@ const RISK_COLOR = { low: 'success', medium: 'warning', high: 'danger' };
|
||||
|
||||
function toForm(p) {
|
||||
return {
|
||||
editVersion: p.edit_version,
|
||||
name: p.name, description: p.description || '', objective: p.objective || '',
|
||||
projectTypeId: p.project_type_id || '', priorityId: p.priority_id, statusId: p.status_id,
|
||||
ownerUserId: p.owner_user_id,
|
||||
@@ -40,9 +43,16 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
||||
const { data: users } = useAsync(listUsers, []);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState(() => toForm(project));
|
||||
|
||||
useEffect(() => { setForm(toForm(project)); }, [project]);
|
||||
// Borrador de la edicion: sobrevive a cambiar de pestaña/menu, F5 y vencimiento de sesion.
|
||||
const draft = useDraftForm({
|
||||
formId: 'project-info', entityId: project.id, initial: toForm(project),
|
||||
label: `${project.code} — ${t('tab_info')}`, onDiscard: () => setEditing(false),
|
||||
});
|
||||
const form = draft.values;
|
||||
const setForm = draft.setValues;
|
||||
const dirty = (name) => editing && draft.isFieldDirty(name);
|
||||
// Al volver con un borrador pendiente, la edicion se reabre con los valores recuperados.
|
||||
useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]);
|
||||
|
||||
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
|
||||
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
||||
@@ -50,10 +60,10 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
||||
const typeOptions = (types || []).map((tp) => ({ value: tp.id, label: tp.name }));
|
||||
|
||||
async function save() {
|
||||
const changed = JSON.stringify(form) !== JSON.stringify(toForm(project));
|
||||
if (!changed) { setEditing(false); return; }
|
||||
await runAction(async () => {
|
||||
if (!draft.isDirty) { draft.discard(); setEditing(false); return; }
|
||||
await draft.guardSave(() => runAction(async () => {
|
||||
await updateProject(project.id, {
|
||||
editVersion: form.editVersion,
|
||||
...form,
|
||||
projectTypeId: form.projectTypeId || null,
|
||||
requestingDepartmentId: form.requestingDepartmentId || null,
|
||||
@@ -62,10 +72,11 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
||||
estimatedEndDate: form.estimatedEndDate || null,
|
||||
actualEndDate: form.actualEndDate || null,
|
||||
});
|
||||
draft.clear();
|
||||
setEditing(false);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
onChanged();
|
||||
});
|
||||
}), { fetchCurrent: async () => toForm(await getProject(project.id)) });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -74,7 +85,7 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
||||
{editing ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
<Button variant="standard" icon={X} onClick={() => { setForm(toForm(project)); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" icon={X} onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
|
||||
@@ -108,54 +119,55 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel-card">
|
||||
<Field label={t('common_name')}>
|
||||
<DraftNotice draft={draft} />
|
||||
<Field label={t('common_name')} dirty={dirty('name')}>
|
||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label={t('common_description')}>
|
||||
<Field label={t('common_description')} dirty={dirty('description')}>
|
||||
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label={t('project_objective')}>
|
||||
<Field label={t('project_objective')} dirty={dirty('objective')}>
|
||||
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
|
||||
</Field>
|
||||
<FieldRow>
|
||||
<Field label={t('project_type')}>
|
||||
<Field label={t('project_type')} dirty={dirty('projectTypeId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('common_priority')}>
|
||||
<Field label={t('common_priority')} dirty={dirty('priorityId')}>
|
||||
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('common_status')}>
|
||||
<Field label={t('common_status')} dirty={dirty('statusId')}>
|
||||
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('project_owner')}>
|
||||
<Field label={t('project_owner')} dirty={dirty('ownerUserId')}>
|
||||
<ComboBox options={(users || []).map((u) => ({ value: u.id, label: u.name }))} value={form.ownerUserId} onChange={(v) => setForm((f) => ({ ...f, ownerUserId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('project_risk')}>
|
||||
<Field label={t('project_risk')} dirty={dirty('riskLevel')}>
|
||||
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('project_requesting_department')}>
|
||||
<Field label={t('project_requesting_department')} dirty={dirty('requestingDepartmentId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('project_responsible_department')}>
|
||||
<Field label={t('project_responsible_department')} dirty={dirty('responsibleDepartmentId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('project_start_date')}>
|
||||
<Field label={t('project_start_date')} dirty={dirty('startDate')}>
|
||||
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label={t('project_estimated_end_date')}>
|
||||
<Field label={t('project_estimated_end_date')} dirty={dirty('estimatedEndDate')}>
|
||||
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label={t('project_actual_end_date')}>
|
||||
<Field label={t('project_actual_end_date')} dirty={dirty('actualEndDate')}>
|
||||
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: e.target.value }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<Field label={t('project_notes')}>
|
||||
<Field label={t('project_notes')} dirty={dirty('notes')}>
|
||||
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { UserPlus, Trash2 } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
@@ -10,6 +10,9 @@ import { Field } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { useDraftForm, useUserDrafts, hasDraftFor } from '../../hooks/useDraftForm.js';
|
||||
|
||||
const ROLE_BADGE = {
|
||||
owner: { color: 'accent', key: 'project_role_owner' },
|
||||
@@ -22,10 +25,20 @@ export default function ProjectMembersTab({ project, onChanged }) {
|
||||
const runAction = useAction();
|
||||
const { data: users } = useAsync(listUsers, []);
|
||||
|
||||
const [addingMember, setAddingMember] = useState('');
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [newMemberRole, setNewMemberRole] = useState('read');
|
||||
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||
// Borrador del popup "Agregar miembro" (por proyecto).
|
||||
const draft = useDraftForm({
|
||||
formId: 'project-member-add', entityId: project.id,
|
||||
initial: { userId: '', email: '', role: 'read' },
|
||||
label: `${project.code} — ${t('project_add_member')}`, enabled: addMemberOpen,
|
||||
});
|
||||
const addingMember = draft.values.userId;
|
||||
const inviteEmail = draft.values.email;
|
||||
const newMemberRole = draft.values.role;
|
||||
const setNewMemberRole = (v) => draft.setField('role', v);
|
||||
const guardedClose = useGuardedClose();
|
||||
const pendingAdd = hasDraftFor(useUserDrafts(), 'project-member-add', project.id);
|
||||
useEffect(() => { if (pendingAdd) setAddMemberOpen(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Compartir (agregar/quitar/cambiar permiso) es exclusivo del dueño del proyecto -- el resto
|
||||
// solo ve la lista de con quien esta compartido.
|
||||
@@ -42,9 +55,7 @@ export default function ProjectMembersTab({ project, onChanged }) {
|
||||
? { email: inviteEmail.trim() }
|
||||
: { userId: Number(addingMember) };
|
||||
const result = await addProjectMember(project.id, target, newMemberRole === 'write' ? 'write' : null);
|
||||
setAddingMember('');
|
||||
setInviteEmail('');
|
||||
setNewMemberRole('read');
|
||||
draft.clear();
|
||||
setAddMemberOpen(false);
|
||||
onChanged();
|
||||
pushToast({ tone: 'success', message: result.invited ? t('project_invite_sent') : t('common_saved') });
|
||||
@@ -75,7 +86,7 @@ export default function ProjectMembersTab({ project, onChanged }) {
|
||||
<div className="panel-card">
|
||||
<div className="toolbar">
|
||||
<h3 style={{ margin: 0 }}>{t('project_members')}</h3>
|
||||
{canManage && <Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}</Button>}
|
||||
{canManage && <Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}{pendingAdd && !addMemberOpen && <DraftMark />}</Button>}
|
||||
</div>
|
||||
{!canManage && <div className="text-muted" style={{ fontSize: '.76rem', marginBottom: 10 }}>{t('project_members_owner_only_hint')}</div>}
|
||||
{project.members.map((m) => {
|
||||
@@ -103,29 +114,30 @@ export default function ProjectMembersTab({ project, onChanged }) {
|
||||
{project.members.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
||||
</div>
|
||||
|
||||
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={420}>
|
||||
<Field label={t('project_add_member')}>
|
||||
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => guardedClose(draft, () => setAddMemberOpen(false))} width={420} height={460}>
|
||||
<DraftNotice draft={draft} />
|
||||
<Field label={t('project_add_member')} dirty={draft.isFieldDirty('userId')}>
|
||||
<ComboBox
|
||||
options={userOptions}
|
||||
value={addingMember}
|
||||
onChange={(v) => { setAddingMember(v); setInviteEmail(''); }}
|
||||
onChange={(v) => draft.setValues((f) => ({ ...f, userId: v, email: '' }))}
|
||||
placeholder={t('project_add_member')}
|
||||
/>
|
||||
</Field>
|
||||
<div className="text-muted" style={{ fontSize: '.76rem', margin: '8px 0', textAlign: 'center' }}>{t('common_or')}</div>
|
||||
<Field label={t('project_invite_email')} hint={t('project_invite_email_hint')}>
|
||||
<Field label={t('project_invite_email')} hint={t('project_invite_email_hint')} dirty={draft.isFieldDirty('email')}>
|
||||
<input
|
||||
className="input" type="email" value={inviteEmail}
|
||||
onChange={(e) => { setInviteEmail(e.target.value); setAddingMember(''); }}
|
||||
onChange={(e) => draft.setValues((f) => ({ ...f, email: e.target.value, userId: '' }))}
|
||||
placeholder="correo@dominio.com"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('project_add_member_role')}>
|
||||
<Field label={t('project_add_member_role')} dirty={draft.isFieldDirty('role')}>
|
||||
<ComboBox options={roleOptions} value={newMemberRole} onChange={setNewMemberRole} />
|
||||
</Field>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<Button variant="accent" icon={UserPlus} onClick={addMember} disabled={!canAdd}>{t('common_save')}</Button>
|
||||
<Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" onClick={() => { draft.discard(); setAddMemberOpen(false); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync } from '../../hooks/useAsync.js';
|
||||
import { useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||
import { getProject } from '../../services/projects.js';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { Tabs } from '../../components/Tabs.jsx';
|
||||
@@ -20,10 +20,26 @@ export default function ProyectoDetallePage() {
|
||||
const projectId = Number(id);
|
||||
const { t } = useUi();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// La pestaña vive en la URL (?tab=): asi el retorno tras el login (o un F5) vuelve a la misma
|
||||
// pestaña, y el aviso de cambios sin guardar intercepta el cambio de pestaña.
|
||||
// Llegada desde el buscador (Proyectos): ?tab=activities&activity=<id>&activityTab=tasks abre
|
||||
// directo la actividad (y su pestaña de tareas) que coincidio con la busqueda.
|
||||
const [tab, setTab] = useState(searchParams.get('tab') || 'info');
|
||||
const tab = searchParams.get('tab') || 'info';
|
||||
const setTab = (key) => setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set('tab', key);
|
||||
next.delete('activity');
|
||||
next.delete('activityTab');
|
||||
return next;
|
||||
}, { replace: true });
|
||||
// Pestañas con borradores pendientes de este proyecto (el borrador guarda la URL con su ?tab=).
|
||||
const drafts = useUserDrafts();
|
||||
const dirtyTab = (key) => drafts.some((d) => {
|
||||
if (typeof d.path !== 'string') return false;
|
||||
const [path, query = ''] = d.path.split('?');
|
||||
return path === `/proyectos/${projectId}` && (new URLSearchParams(query).get('tab') || 'info') === key;
|
||||
});
|
||||
const deepLinkActivityId = searchParams.get('activity') ? Number(searchParams.get('activity')) : null;
|
||||
const deepLinkActivityTab = searchParams.get('activityTab') || undefined;
|
||||
|
||||
@@ -43,13 +59,13 @@ export default function ProyectoDetallePage() {
|
||||
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ key: 'info', label: t('tab_info') },
|
||||
{ key: 'members', label: t('tab_members'), count: project.members_count },
|
||||
{ key: 'activities', label: t('tab_activities'), count: `${project.activities_complete}/${project.activities_total}` },
|
||||
{ key: 'info', label: t('tab_info'), dirty: dirtyTab('info') },
|
||||
{ key: 'members', dirty: dirtyTab('members'), label: t('tab_members'), count: project.members_count },
|
||||
{ key: 'activities', dirty: dirtyTab('activities'), label: t('tab_activities'), count: `${project.activities_complete}/${project.activities_total}` },
|
||||
{ key: 'gantt', label: t('tab_gantt') },
|
||||
{ key: 'calendar', label: t('tab_calendar') },
|
||||
{ key: 'comments', label: t('tab_comments'), count: project.comments_count },
|
||||
{ key: 'documents', label: t('tab_documents'), count: project.documents_count },
|
||||
{ key: 'comments', dirty: dirtyTab('comments'), label: t('tab_comments'), count: project.comments_count },
|
||||
{ key: 'documents', dirty: dirtyTab('documents'), label: t('tab_documents'), count: project.documents_count },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Save, Trash2, RotateCcw, ArrowLeft } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
@@ -19,6 +19,9 @@ import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { useDraftForm, useUserDrafts, hasDraftFor } from '../../hooks/useDraftForm.js';
|
||||
import { SharedWithIcon } from '../../components/SharedWithIcon.jsx';
|
||||
|
||||
const CAN_DELETE_ROLES = ['superadmin', 'admin', 'it_manager', 'project_lead'];
|
||||
@@ -50,6 +53,8 @@ export default function ProyectosPage() {
|
||||
const [filterStatusId, setFilterStatusId] = useState('');
|
||||
const [filterPriorityId, setFilterPriorityId] = useState('');
|
||||
const [filterDueSoon, setFilterDueSoon] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
// '' = todos, 'hide' = ocultar 100%, 'only' = solo 100%. Persistido en el navegador (sobrevive
|
||||
// al cierre de sesion). Respeta el checkbox anterior "Ocultar 100%" (clave vieja) si existia.
|
||||
const [progressFilter, setProgressFilter] = useState(() => {
|
||||
@@ -67,25 +72,36 @@ export default function ProyectosPage() {
|
||||
// Prueba exploratoria: filtro "ver todos / por usuario" solo para roles que ya ven todos los
|
||||
// proyectos hoy (superadmin/admin/it_manager) -- backend ya soporta ownerUserId, sin cambios.
|
||||
const canFilterByUser = ['superadmin', 'admin', 'it_manager'].includes(currentUser?.roleCode);
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search, 300);
|
||||
|
||||
const { data: projects, reload } = useAsync(
|
||||
() => listProjects(showTrash ? { trash: true } : { statusId: filterStatusId, priorityId: filterPriorityId, ownerUserId: filterOwnerUserId || undefined }),
|
||||
[showTrash, filterStatusId, filterPriorityId, filterOwnerUserId]
|
||||
const { data: projectPage, reload } = useAsync(
|
||||
() => listProjects(showTrash ? { trash: true, page, pageSize, q: debouncedSearch } : {
|
||||
statusId: filterStatusId, priorityId: filterPriorityId, ownerUserId: filterOwnerUserId || undefined,
|
||||
dueSoon: filterDueSoon, progress: progressFilter, q: debouncedSearch, page, pageSize,
|
||||
}),
|
||||
[showTrash, filterStatusId, filterPriorityId, filterOwnerUserId, filterDueSoon, progressFilter, debouncedSearch, page, pageSize]
|
||||
);
|
||||
const projects = projectPage?.items;
|
||||
const { data: allUsers } = useAsync(listUsers, []);
|
||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
|
||||
const { data: types } = useAsync(() => listCatalog('project-types'), []);
|
||||
const { data: deptTree } = useAsync(listDepartments, []);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
useEffect(() => { setPage(1); }, [showTrash, filterStatusId, filterPriorityId, filterOwnerUserId, filterDueSoon, progressFilter, search, pageSize]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
// "Nuevo proyecto" con borrador: si se sale a medio llenar, al volver se reabre el popup.
|
||||
const draft = useDraftForm({ formId: 'project-new', entityId: null, initial: EMPTY_FORM, label: t('project_new'), enabled: creating });
|
||||
const form = draft.values;
|
||||
const setForm = draft.setValues;
|
||||
const guardedClose = useGuardedClose();
|
||||
const pendingNew = hasDraftFor(useUserDrafts(), 'project-new', null);
|
||||
useEffect(() => { if (pendingNew) setCreating(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Busca tambien en actividades y tareas de CUALQUIER proyecto (no solo el nombre del proyecto,
|
||||
// que ya filtra la tabla mas abajo via getSearchHaystack) — resultados con su ruta completa
|
||||
// "Proyecto - Actividad - Tarea" en un panel flotante bajo el buscador.
|
||||
const debouncedSearch = useDebouncedValue(search, 300);
|
||||
const searchQuery = debouncedSearch.trim();
|
||||
const { data: searchResults } = useAsync(
|
||||
() => (searchQuery.length >= 2 ? searchApi(searchQuery) : Promise.resolve(null)),
|
||||
@@ -125,8 +141,8 @@ export default function ProyectosPage() {
|
||||
startDate: form.startDate || null,
|
||||
estimatedEndDate: form.estimatedEndDate || null,
|
||||
});
|
||||
draft.clear();
|
||||
setCreating(false);
|
||||
setForm(EMPTY_FORM);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
navigate(`/proyectos/${project.id}`);
|
||||
});
|
||||
@@ -193,16 +209,7 @@ export default function ProyectosPage() {
|
||||
|
||||
if (!projects) return <div className="text-muted">{t('common_loading')}</div>;
|
||||
|
||||
const dueSoonProjects = filterDueSoon && !showTrash
|
||||
? projects.filter((p) => {
|
||||
if (p.status_is_closed || !p.estimated_end_date) return false;
|
||||
const diffDays = Math.round((new Date(p.estimated_end_date) - new Date()) / 86400000);
|
||||
return diffDays >= 0 && diffDays <= 7;
|
||||
})
|
||||
: projects;
|
||||
const visibleProjects = showTrash || !progressFilter
|
||||
? dueSoonProjects
|
||||
: dueSoonProjects.filter((p) => (Number(p.progress_pct) >= 100) === (progressFilter === 'only'));
|
||||
const visibleProjects = projects;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -217,7 +224,7 @@ export default function ProyectosPage() {
|
||||
{isSuperadmin && (
|
||||
<Button variant="standard" icon={Trash2} onClick={() => setShowTrash(true)}>{t('project_trash')}</Button>
|
||||
)}
|
||||
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}</Button>
|
||||
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}{pendingNew && !creating && <DraftMark />}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -284,7 +291,7 @@ export default function ProyectosPage() {
|
||||
<ListView
|
||||
columns={columns}
|
||||
rows={visibleProjects}
|
||||
searchValue={search}
|
||||
searchValue=""
|
||||
getSearchHaystack={(p) => `${p.code} ${p.name} ${p.owner_name} ${p.status_name}`}
|
||||
getRowKey={(p) => p.id}
|
||||
onRowClick={showTrash ? undefined : (p) => navigate(`/proyectos/${p.id}`)}
|
||||
@@ -301,52 +308,60 @@ export default function ProyectosPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<DetailPopup open={creating} title={t('project_new')} onClose={() => setCreating(false)} width={720} height={720}>
|
||||
<Field label={t('common_name')}>
|
||||
<div className="toolbar" style={{ justifyContent: 'flex-end', marginTop: 8 }}>
|
||||
<span className="text-muted">{t('project_page_range')} {projectPage.total ? (page - 1) * pageSize + 1 : 0}–{Math.min(page * pageSize, projectPage.total)} {t('project_page_of')} {projectPage.total}</span>
|
||||
<ComboBox options={[10, 20, 50, 100].map((n) => ({ value: n, label: String(n) }))} value={pageSize} onChange={setPageSize} />
|
||||
<Button variant="standard" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>{t('project_page_previous')}</Button>
|
||||
<Button variant="standard" disabled={page * pageSize >= projectPage.total} onClick={() => setPage((p) => p + 1)}>{t('project_page_next')}</Button>
|
||||
</div>
|
||||
|
||||
<DetailPopup open={creating} title={t('project_new')} onClose={() => guardedClose(draft, () => setCreating(false))} width={720} height={720}>
|
||||
<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')}>
|
||||
<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>
|
||||
<Field label={t('project_objective')}>
|
||||
<Field label={t('project_objective')} dirty={draft.isFieldDirty('objective')}>
|
||||
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
|
||||
</Field>
|
||||
<FieldRow>
|
||||
<Field label={t('project_type')}>
|
||||
<Field label={t('project_type')} dirty={draft.isFieldDirty('projectTypeId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('common_priority')}>
|
||||
<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')}>
|
||||
<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('project_requesting_department')}>
|
||||
<Field label={t('project_requesting_department')} dirty={draft.isFieldDirty('requestingDepartmentId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('project_responsible_department')}>
|
||||
<Field label={t('project_responsible_department')} dirty={draft.isFieldDirty('responsibleDepartmentId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('project_risk')}>
|
||||
<Field label={t('project_risk')} dirty={draft.isFieldDirty('riskLevel')}>
|
||||
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('project_start_date')}>
|
||||
<Field label={t('project_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('project_estimated_end_date')}>
|
||||
<Field label={t('project_estimated_end_date')} dirty={draft.isFieldDirty('estimatedEndDate')}>
|
||||
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<Field label={t('project_notes')}>
|
||||
<Field label={t('project_notes')} dirty={draft.isFieldDirty('notes')}>
|
||||
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
||||
</Field>
|
||||
<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>
|
||||
<Button variant="standard" onClick={() => { draft.discard(); setCreating(false); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,16 @@ import {
|
||||
completeRecurringTaskCycle,
|
||||
} from '../../services/recurringTasks.js';
|
||||
import { listUsers } from '../../services/users.js';
|
||||
import { listProjects } from '../../services/projects.js';
|
||||
import { listProjectOptions } from '../../services/projects.js';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||
|
||||
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `${String(h).padStart(2, '0')}:00` }));
|
||||
|
||||
@@ -28,7 +31,7 @@ function toForm(r) {
|
||||
name: r.name, description: r.description || '', projectId: r.project_id || '',
|
||||
responsibleUserId: r.responsible_user_id || '', intervalUnit: r.interval_unit,
|
||||
intervalValue: r.interval_value, nextDueDate: r.next_due_date, endDate: r.end_date || '',
|
||||
reminderDaysBefore: r.reminder_days_before, reminderHour: r.reminder_hour,
|
||||
reminderDaysBefore: r.reminder_days_before, reminderHour: r.reminder_hour, editVersion: r.edit_version,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,11 +40,24 @@ export default function RecurringTasksPage() {
|
||||
const runAction = useAction();
|
||||
const { data: items, reload } = useAsync(listRecurringTasks, []);
|
||||
const { data: users } = useAsync(listUsers, []);
|
||||
const { data: projects } = useAsync(() => listProjects(), []);
|
||||
const { data: projects } = useAsync(listProjectOptions, []);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
// Borrador del popup (nueva o edicion de una existente).
|
||||
const draft = useDraftForm({
|
||||
formId: selected ? 'recurring-edit' : 'recurring-new', entityId: selected?.id ?? null,
|
||||
initial: selected ? toForm(selected) : EMPTY_FORM,
|
||||
label: selected ? `${t('common_edit')}: ${selected.name}` : t('recurring_task_new'), enabled: editing,
|
||||
});
|
||||
const form = draft.values;
|
||||
const setForm = draft.setValues;
|
||||
const guardedClose = useGuardedClose();
|
||||
const pendingNew = hasDraftFor(useUserDrafts(), 'recurring-new', null);
|
||||
useReopenDraft({
|
||||
newFormId: 'recurring-new', editFormId: 'recurring-edit', items,
|
||||
openNew: () => startNew(), openEdit: (r) => startEdit(r),
|
||||
});
|
||||
|
||||
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
|
||||
const projectOptions = (projects || []).map((p) => ({ value: p.id, label: `${p.code} — ${p.name}` }));
|
||||
@@ -54,26 +70,27 @@ export default function RecurringTasksPage() {
|
||||
|
||||
function startNew() {
|
||||
setSelected(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function startEdit(r) {
|
||||
setSelected(r);
|
||||
setForm(toForm(r));
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim() || !form.nextDueDate) return;
|
||||
await runAction(async () => {
|
||||
const run = () => runAction(async () => {
|
||||
const payload = { ...form, projectId: form.projectId || null, responsibleUserId: form.responsibleUserId || null, endDate: form.endDate || null };
|
||||
if (selected) await updateRecurringTask(selected.id, payload);
|
||||
else await createRecurringTask(payload);
|
||||
draft.clear();
|
||||
setEditing(false);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
await reload();
|
||||
});
|
||||
// Edicion: si el registro cambio en el servidor desde que se empezo a editar, se confirma.
|
||||
if (selected) await draft.guardSave(run); else await run();
|
||||
}
|
||||
|
||||
function confirmDelete(r) {
|
||||
@@ -135,7 +152,7 @@ export default function RecurringTasksPage() {
|
||||
<div className="toolbar-left">
|
||||
<h2 className="page-title mt-0">{t('nav_recurring_tasks')}</h2>
|
||||
</div>
|
||||
<Button variant="accent" icon={Plus} onClick={startNew}>{t('recurring_task_new')}</Button>
|
||||
<Button variant="accent" icon={Plus} onClick={startNew}>{t('recurring_task_new')}{pendingNew && !editing && <DraftMark />}</Button>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
@@ -154,51 +171,52 @@ export default function RecurringTasksPage() {
|
||||
<DetailPopup
|
||||
open={editing}
|
||||
title={selected ? t('common_edit') : t('recurring_task_new')}
|
||||
onClose={() => setEditing(false)}
|
||||
onClose={() => guardedClose(draft, () => setEditing(false))}
|
||||
width={640}
|
||||
height={720}
|
||||
height={760}
|
||||
>
|
||||
<Field label={t('common_name')}>
|
||||
<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')}>
|
||||
<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('recurring_task_project')}>
|
||||
<Field label={t('recurring_task_project')} dirty={draft.isFieldDirty('projectId')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...projectOptions]} value={form.projectId} onChange={(v) => setForm((f) => ({ ...f, projectId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('common_responsible')}>
|
||||
<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>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('recurring_task_every')}>
|
||||
<Field label={t('recurring_task_every')} dirty={draft.isFieldDirty('intervalValue')}>
|
||||
<input className="input" type="number" min={1} value={form.intervalValue} onChange={(e) => setForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} />
|
||||
</Field>
|
||||
<Field label={t('recurring_task_interval_unit')}>
|
||||
<Field label={t('recurring_task_interval_unit')} dirty={draft.isFieldDirty('intervalUnit')}>
|
||||
<ComboBox options={unitOptions} value={form.intervalUnit} onChange={(v) => setForm((f) => ({ ...f, intervalUnit: v }))} />
|
||||
</Field>
|
||||
<Field label={t('recurring_task_next_due')}>
|
||||
<Field label={t('recurring_task_next_due')} dirty={draft.isFieldDirty('nextDueDate')}>
|
||||
<input className="input" type="date" value={form.nextDueDate} onChange={(e) => setForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')}>
|
||||
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')} dirty={draft.isFieldDirty('endDate')}>
|
||||
<input className="input" type="date" value={form.endDate} onChange={(e) => setForm((f) => ({ ...f, endDate: e.target.value }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('recurring_task_reminder_days')}>
|
||||
<Field label={t('recurring_task_reminder_days')} dirty={draft.isFieldDirty('reminderDaysBefore')}>
|
||||
<input className="input" type="number" min={0} value={form.reminderDaysBefore} onChange={(e) => setForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />
|
||||
</Field>
|
||||
<Field label={t('recurring_task_reminder_hour')}>
|
||||
<Field label={t('recurring_task_reminder_hour')} dirty={draft.isFieldDirty('reminderHour')}>
|
||||
<ComboBox options={HOUR_OPTIONS} value={form.reminderHour} onChange={(v) => setForm((f) => ({ ...f, reminderHour: Number(v) }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user