feat: version 4.16.0 -- Equipos: tipo de licencia y software agrupado por fabricante
Clasificador por reglas sin IA, correccion manual (migracion 040) y filas desplegables en ListView. Flyout por encima de popups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "4.15.1",
|
||||
"version": "4.16.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { MoreVertical, GripVertical } from 'lucide-react';
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { MoreVertical, GripVertical, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { useUi } from '../context/ui-context.jsx';
|
||||
import { useDragReorder } from '../hooks/useDragReorder.js';
|
||||
import { Flyout, FlyoutItem } from './Flyout.jsx';
|
||||
@@ -9,15 +9,25 @@ import { Flyout, FlyoutItem } from './Flyout.jsx';
|
||||
// `onReorder(idsEnNuevoOrden)` es opcional: si se pasa, habilita arrastrar filas con el mouse para
|
||||
// definir un orden manual (columna con icono de agarre a la izquierda) — solo mientras no haya un
|
||||
// ordenamiento por columna activo, para no mezclar los dos mecanismos de orden a la vez.
|
||||
export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey, initialSort, onReorder }) {
|
||||
// `getChildren(fila)` es opcional: si devuelve filas, la fila se pliega/despliega con su boton o con
|
||||
// un clic y las hijas se muestran debajo. Al buscar, solo quedan las hijas que coinciden y se abren.
|
||||
export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey, initialSort, onReorder, getChildren }) {
|
||||
const { t } = useUi();
|
||||
const [sort, setSort] = useState(initialSort || { key: null, dir: 1 });
|
||||
const [expanded, setExpanded] = useState(() => new Set());
|
||||
const haystack = (r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase();
|
||||
const toggleRow = (key) => setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(key)) next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchValue) return rows;
|
||||
const q = searchValue.toLowerCase();
|
||||
return rows.filter((r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase().includes(q));
|
||||
}, [rows, searchValue, getSearchHaystack]);
|
||||
return rows.filter((r) => haystack(r).includes(q) || getChildren?.(r)?.some((c) => haystack(c).includes(q)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, searchValue, getSearchHaystack, getChildren]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sort.key) return filtered;
|
||||
@@ -38,6 +48,55 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
|
||||
setSort((prev) => (prev.key === key ? { key, dir: prev.dir * -1 } : { key, dir: 1 }));
|
||||
}
|
||||
|
||||
function renderRow(row, { isParent, open, onToggle, child } = {}) {
|
||||
const actions = rowActions?.(row) || [];
|
||||
const activate = isParent ? onToggle : onRowClick ? () => onRowClick(row) : null;
|
||||
return (
|
||||
<tr
|
||||
key={getRowKey(row)}
|
||||
onClick={() => activate?.()}
|
||||
onContextMenu={actions.length ? (event) => { event.preventDefault(); event.currentTarget.querySelector('.row-menu-btn')?.click(); } : undefined}
|
||||
tabIndex={activate ? 0 : undefined}
|
||||
onKeyDown={(event) => { if (activate && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); activate(); } }}
|
||||
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
|
||||
{...(dragEnabled ? getRowProps(row) : {})}
|
||||
>
|
||||
{dragEnabled && (
|
||||
<td className="drag-handle-cell" onClick={(e) => e.stopPropagation()} {...getHandleProps(row)}>
|
||||
<GripVertical size={15} />
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col, index) => (
|
||||
<td key={col.key} style={index === 0 && child ? { paddingLeft: 38 } : undefined}>
|
||||
{index === 0 && isParent ? (
|
||||
<span className="list-tree-cell">
|
||||
<button type="button" className="tree-toggle" aria-expanded={open} aria-label={t(open ? 'common_collapse' : 'common_expand')} onClick={(e) => { e.stopPropagation(); onToggle(); }}>
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
{col.render ? col.render(row) : row[col.key]}
|
||||
</span>
|
||||
) : (col.render ? col.render(row) : row[col.key])}
|
||||
</td>
|
||||
))}
|
||||
{rowActions && (
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
{actions.length > 0 && (
|
||||
<Flyout trigger={({ onClick }) => (
|
||||
<button className="icon-btn row-menu-btn" aria-label={t('common_actions')} onClick={onClick}><MoreVertical /></button>
|
||||
)}>
|
||||
{actions.map((action, i) => (
|
||||
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
|
||||
{action.label}
|
||||
</FlyoutItem>
|
||||
))}
|
||||
</Flyout>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="list-card">
|
||||
<table className="list-table">
|
||||
@@ -53,39 +112,20 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayRows.map((row) => (
|
||||
<tr
|
||||
key={getRowKey(row)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
onContextMenu={rowActions ? (event) => { event.preventDefault(); event.currentTarget.querySelector('.row-menu-btn')?.click(); } : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
onKeyDown={(event) => { if (onRowClick && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onRowClick(row); } }}
|
||||
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
|
||||
{...(dragEnabled ? getRowProps(row) : {})}
|
||||
>
|
||||
{dragEnabled && (
|
||||
<td className="drag-handle-cell" onClick={(e) => e.stopPropagation()} {...getHandleProps(row)}>
|
||||
<GripVertical size={15} />
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<td key={col.key}>{col.render ? col.render(row) : row[col.key]}</td>
|
||||
))}
|
||||
{rowActions && (
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<Flyout trigger={({ onClick }) => (
|
||||
<button className="icon-btn row-menu-btn" aria-label={t('common_actions')} onClick={onClick}><MoreVertical /></button>
|
||||
)}>
|
||||
{rowActions(row).map((action, i) => (
|
||||
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
|
||||
{action.label}
|
||||
</FlyoutItem>
|
||||
))}
|
||||
</Flyout>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{displayRows.map((row) => {
|
||||
const key = getRowKey(row);
|
||||
const allChildren = getChildren?.(row);
|
||||
const isParent = !!allChildren?.length;
|
||||
const q = searchValue?.toLowerCase();
|
||||
const children = isParent && q ? allChildren.filter((c) => haystack(c).includes(q)) : allChildren;
|
||||
const open = isParent && (expanded.has(key) || (!!q && children.length > 0));
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
{renderRow(row, { isParent, open, onToggle: () => toggleRow(key) })}
|
||||
{open && children.map((child) => renderRow(child, { child: true }))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{displayRows.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
|
||||
|
||||
@@ -294,6 +294,8 @@ export const messages = {
|
||||
common_search: 'Buscar...',
|
||||
common_select: 'Seleccionar',
|
||||
common_actions: 'Acciones',
|
||||
common_expand: 'Desplegar',
|
||||
common_collapse: 'Plegar',
|
||||
common_search_no_results: 'Sin resultados',
|
||||
common_actions: 'Acciones',
|
||||
common_view: 'Ver',
|
||||
@@ -498,6 +500,18 @@ export const messages = {
|
||||
eq_after: 'Después',
|
||||
eq_history_empty: 'Sin cambios registrados entre reportes.',
|
||||
eq_tab_licenses: 'Licenciamiento',
|
||||
eq_sw_type: 'Tipo',
|
||||
eq_sw_type_all: 'Todos los tipos',
|
||||
eq_sw_type_free: 'Gratuito',
|
||||
eq_sw_type_paid: 'Pago',
|
||||
eq_sw_type_personal_free: 'Gratis solo personal',
|
||||
eq_sw_type_component: 'Componente o incluido',
|
||||
eq_sw_type_unclassified: 'Sin clasificar',
|
||||
eq_sw_programs: 'programas',
|
||||
eq_sw_classify_as: 'Marcar como',
|
||||
eq_sw_classify_auto: 'Volver a automático',
|
||||
eq_sw_manual: 'Tipo corregido a mano',
|
||||
eq_sw_hint: 'Clasificación orientativa por nombre y fabricante: no es un dictamen legal ni detecta software sin licencia.',
|
||||
eq_lic_windows: 'Windows',
|
||||
eq_lic_office: 'Office',
|
||||
eq_lic_office_installed: 'Office instalado',
|
||||
@@ -1076,6 +1090,8 @@ export const messages = {
|
||||
common_search: 'Search...',
|
||||
common_select: 'Select',
|
||||
common_actions: 'Actions',
|
||||
common_expand: 'Expand',
|
||||
common_collapse: 'Collapse',
|
||||
common_search_no_results: 'No results',
|
||||
common_actions: 'Actions',
|
||||
common_view: 'View',
|
||||
@@ -1280,6 +1296,18 @@ export const messages = {
|
||||
eq_after: 'After',
|
||||
eq_history_empty: 'No changes recorded between reports.',
|
||||
eq_tab_licenses: 'Licensing',
|
||||
eq_sw_type: 'Type',
|
||||
eq_sw_type_all: 'All types',
|
||||
eq_sw_type_free: 'Free',
|
||||
eq_sw_type_paid: 'Paid',
|
||||
eq_sw_type_personal_free: 'Free for personal use only',
|
||||
eq_sw_type_component: 'Component or bundled',
|
||||
eq_sw_type_unclassified: 'Unclassified',
|
||||
eq_sw_programs: 'programs',
|
||||
eq_sw_classify_as: 'Mark as',
|
||||
eq_sw_classify_auto: 'Back to automatic',
|
||||
eq_sw_manual: 'Type set manually',
|
||||
eq_sw_hint: 'Indicative classification by name and publisher: it is not a legal opinion and does not detect unlicensed software.',
|
||||
eq_lic_windows: 'Windows',
|
||||
eq_lic_office: 'Office',
|
||||
eq_lic_office_installed: 'Installed Office',
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ListView } from '../../components/ListView.jsx';
|
||||
import { Tabs } from '../../components/Tabs.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
import { KpiCard } from '../../components/KpiCard.jsx';
|
||||
import { SoftwareList, SoftwareTypeFilter, matchesSoftwareType } from './SoftwareList.jsx';
|
||||
import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js';
|
||||
|
||||
const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other'];
|
||||
@@ -69,6 +70,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
||||
const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
|
||||
const [searches, setSearches] = useState({});
|
||||
const [showComponents, setShowComponents] = useState(false);
|
||||
const [softwareType, setSoftwareType] = useState('');
|
||||
const [fetchingYear, setFetchingYear] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
|
||||
@@ -193,7 +195,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
||||
const localUsers = only(allLocalUsers, (r) => [r.name, r.sid]);
|
||||
const localGroups = only(allLocalGroups, (r) => [r.name, r.sid]);
|
||||
const networks = only(device.networks, (r) => [r.description, r.ip, r.mask, r.gateway, r.mac, r.status]);
|
||||
const software = only(device.software.filter((r) => showComponents || !r.is_component), (r) => [r.name, r.version, r.publisher]);
|
||||
const software = device.software.filter((r) => (showComponents || !r.is_component) && matchesSoftwareType(r, softwareType));
|
||||
const win = device.extra?.licenses?.windows;
|
||||
const channel = (oem) => (oem == null ? null : oem ? t('eq_lic_oem') : t('eq_lic_not_oem'));
|
||||
const winRows = win ? [
|
||||
@@ -213,10 +215,13 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="toolbar" style={{ marginBottom: 10 }}>
|
||||
{tab === 'software' ? (
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
||||
{t('eq_show_components')}
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
||||
{t('eq_show_components')}
|
||||
</label>
|
||||
<SoftwareTypeFilter value={softwareType} onChange={setSoftwareType} />
|
||||
</div>
|
||||
) : <div />}
|
||||
<SearchInput value={searches[tab] || ''} onChange={(v) => setSearches({ ...searches, [tab]: v })} />
|
||||
</div>
|
||||
@@ -304,9 +309,14 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
||||
)}
|
||||
|
||||
{tab === 'software' && (
|
||||
<SimpleTable empty={emptyText} rows={software} columns={[
|
||||
{ key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') }, { key: 'publisher', label: t('eq_publisher') },
|
||||
]} />
|
||||
<SoftwareList
|
||||
rows={software} searchValue={searches.software} canEdit={canEdit} onChanged={reload} initialSort={{ key: 'name', dir: 1 }}
|
||||
columns={[
|
||||
{ key: 'name', label: t('common_name') },
|
||||
{ key: 'version', label: t('eq_version'), render: (r) => dash(r.version), groupRender: () => '—' },
|
||||
{ key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'licenses' && (
|
||||
@@ -446,34 +456,37 @@ function DevicesTab({ canEdit }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SoftwareTab() {
|
||||
function SoftwareTab({ canEdit }) {
|
||||
const { t } = useUi();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [showComponents, setShowComponents] = useState(false);
|
||||
const { data: rows } = useAsync(() => listSoftware(showComponents), [showComponents]);
|
||||
const [softwareType, setSoftwareType] = useState('');
|
||||
const { data: rows, reload } = useAsync(() => listSoftware(showComponents), [showComponents]);
|
||||
const { data: installs } = useAsync(() => (selected ? listSoftwareDevices(selected) : Promise.resolve([])), [selected]);
|
||||
|
||||
if (!rows) return <div className="text-muted">{t('common_loading')}</div>;
|
||||
const columns = [
|
||||
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
|
||||
{ key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) },
|
||||
{ key: 'version_count', label: t('eq_versions') },
|
||||
{ key: 'version_count', label: t('eq_versions'), groupRender: () => '—' },
|
||||
{ key: 'device_count', label: t('eq_installed_on') },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
||||
{t('eq_show_components')}
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
||||
{t('eq_show_components')}
|
||||
</label>
|
||||
<SoftwareTypeFilter value={softwareType} onChange={setSoftwareType} />
|
||||
</div>
|
||||
<SearchInput value={search} onChange={setSearch} />
|
||||
</div>
|
||||
<ListView
|
||||
columns={columns} rows={rows} searchValue={search} getRowKey={(r) => r.name}
|
||||
getSearchHaystack={(r) => `${r.name} ${r.publisher || ''}`} onRowClick={(r) => setSelected(r.name)}
|
||||
initialSort={{ key: 'device_count', dir: -1 }}
|
||||
<SoftwareList
|
||||
rows={rows.filter((r) => matchesSoftwareType(r, softwareType))} columns={columns} searchValue={search} canEdit={canEdit}
|
||||
onChanged={reload} onRowClick={(r) => setSelected(r.name)} initialSort={{ key: 'device_count', dir: -1 }}
|
||||
/>
|
||||
<DetailPopup open={!!selected} title={selected || ''} onClose={() => setSelected(null)} width={620} height={520}>
|
||||
<SimpleTable empty={t('common_loading')} rows={installs || []} columns={[
|
||||
@@ -674,7 +687,7 @@ export default function EquiposPage() {
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{tab === 'summary' && <SummaryTab />}
|
||||
{tab === 'devices' && <DevicesTab canEdit={access.canEdit} />}
|
||||
{tab === 'software' && <SoftwareTab />}
|
||||
{tab === 'software' && <SoftwareTab canEdit={access.canEdit} />}
|
||||
{tab === 'agents' && access.canManage && <AgentsTab />}
|
||||
{tab === 'share' && access.canManage && <ShareTab />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tag, RotateCcw } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAction } from '../../hooks/useAsync.js';
|
||||
import { setSoftwareClassification } from '../../services/devices.js';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
|
||||
const TYPES = ['free', 'paid', 'personal_free', 'component'];
|
||||
const TYPE_COLOR = { free: 'success', paid: 'accent', personal_free: 'warning', component: 'muted' };
|
||||
// El grupo cerrado muestra el tipo de mayor riesgo que tenga dentro: nada queda escondido.
|
||||
const RISK = ['personal_free', 'paid', null, 'free', 'component'];
|
||||
const typeKey = (type) => type || 'unclassified';
|
||||
const getChildren = (row) => row.children;
|
||||
const getRowKey = (row) => row.groupKey || row.id || row.name;
|
||||
|
||||
export function matchesSoftwareType(row, filter) {
|
||||
return !filter || typeKey(row.license_type) === filter;
|
||||
}
|
||||
|
||||
// Agrupa por fabricante desde 2 programas; lo que no tiene grupo sigue como fila normal.
|
||||
function groupRows(rows) {
|
||||
const groups = new Map();
|
||||
for (const row of rows) if (row.group) groups.set(row.group, [...(groups.get(row.group) || []), row]);
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const row of rows) {
|
||||
const children = row.group ? groups.get(row.group) : null;
|
||||
if (!children || children.length < 2) { result.push(row); continue; }
|
||||
if (seen.has(row.group)) continue;
|
||||
seen.add(row.group);
|
||||
result.push({
|
||||
groupKey: `group:${row.group}`, name: row.group, children,
|
||||
publisher: children.find((c) => c.publisher)?.publisher || null,
|
||||
license_type: RISK.find((type) => children.some((c) => (c.license_type || null) === type)) ?? null,
|
||||
device_count: Math.max(...children.map((c) => c.device_count || 0)),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function SoftwareTypeFilter({ value, onChange }) {
|
||||
const { t } = useUi();
|
||||
const options = [{ value: '', label: t('eq_sw_type_all') }, ...[...TYPES, 'unclassified'].map((k) => ({ value: k, label: t(`eq_sw_type_${k}`) }))];
|
||||
return <div style={{ width: 210 }}><ComboBox options={options} value={value} onChange={onChange} /></div>;
|
||||
}
|
||||
|
||||
export function SoftwareList({ rows, columns, searchValue, canEdit, onChanged, onRowClick, initialSort }) {
|
||||
const { t, pushToast } = useUi();
|
||||
const runAction = useAction();
|
||||
const grouped = useMemo(() => groupRows(rows), [rows]);
|
||||
|
||||
function classify(row, licenseType) {
|
||||
runAction(async () => {
|
||||
await setSoftwareClassification(row.name, licenseType);
|
||||
await onChanged();
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const allColumns = [
|
||||
...columns.map((col, index) => (index === 0 ? {
|
||||
...col,
|
||||
render: (r) => (r.children ? <span><strong>{r.name}</strong> <span className="text-muted">· {r.children.length} {t('eq_sw_programs')}</span></span> : col.render ? col.render(r) : r[col.key]),
|
||||
} : { ...col, render: (r) => (r.children && col.groupRender ? col.groupRender(r) : col.render ? col.render(r) : r[col.key]) })),
|
||||
{
|
||||
key: 'license_type', label: t('eq_sw_type'), sortValue: (r) => RISK.indexOf(r.license_type || null),
|
||||
render: (r) => (
|
||||
<span title={r.license_manual ? t('eq_sw_manual') : undefined}>
|
||||
{r.license_type
|
||||
? <Badge color={TYPE_COLOR[r.license_type]}>{t(`eq_sw_type_${r.license_type}`)}{r.license_manual ? ' *' : ''}</Badge>
|
||||
: <Badge color="danger">{t('eq_sw_type_unclassified')}</Badge>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
const rowActions = canEdit ? (r) => (r.children ? [] : [
|
||||
...TYPES.filter((type) => type !== r.license_type).map((type) => ({
|
||||
icon: Tag, label: `${t('eq_sw_classify_as')}: ${t(`eq_sw_type_${type}`)}`, onClick: () => classify(r, type),
|
||||
})),
|
||||
...(r.license_manual ? [{ icon: RotateCcw, label: t('eq_sw_classify_auto'), onClick: () => classify(r, null) }] : []),
|
||||
]) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListView
|
||||
columns={allColumns} rows={grouped} searchValue={searchValue} getRowKey={getRowKey} getChildren={getChildren}
|
||||
getSearchHaystack={(r) => `${r.name} ${r.publisher || ''} ${r.version || ''}`}
|
||||
onRowClick={onRowClick} rowActions={rowActions} initialSort={initialSort}
|
||||
/>
|
||||
<div className="text-muted" style={{ fontSize: '.72rem', marginTop: 12 }}>{t('eq_sw_hint')}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export const getDevice = (id) => api.get(`/devices/${id}`);
|
||||
export const updateDevice = (id, data) => api.put(`/devices/${id}`, data);
|
||||
export const getDevicesDashboard = () => api.get('/devices/dashboard');
|
||||
export const listSoftware = (all) => api.get(`/devices/software${all ? '?all=1' : ''}`);
|
||||
export const setSoftwareClassification = (name, licenseType) => api.put('/devices/software/classification', { name, licenseType });
|
||||
export const listSoftwareDevices = (name) => api.get(`/devices/software/devices?name=${encodeURIComponent(name)}`);
|
||||
|
||||
export const listInventoryTokens = () => api.get('/devices/tokens');
|
||||
|
||||
@@ -230,6 +230,7 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
||||
.list-table tbody tr { cursor: pointer; }
|
||||
.list-table tbody tr:hover { background: rgb(var(--panel)); }
|
||||
.list-table tbody tr:last-child td { border-bottom: none; }
|
||||
.list-tree-cell { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.list-empty { padding: 40px 16px; text-align: center; color: rgb(var(--muted)); }
|
||||
.row-menu-btn { opacity: .6; }
|
||||
.row-menu-btn:hover { opacity: 1; }
|
||||
@@ -336,9 +337,9 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
||||
.toast-error { border-left-color: rgb(var(--danger)); }
|
||||
|
||||
/* --- Flyout / menú contextual --- */
|
||||
.flyout-backdrop { position: fixed; inset: 0; z-index: 90; }
|
||||
.flyout-backdrop { position: fixed; inset: 0; z-index: 260; }
|
||||
.flyout-panel {
|
||||
position: fixed; z-index: 100; background: rgb(var(--surface)); border: 1px solid rgb(var(--line));
|
||||
position: fixed; z-index: 270; background: rgb(var(--surface)); border: 1px solid rgb(var(--line));
|
||||
border-radius: var(--r-lg); box-shadow: 0 10px 28px rgb(0 0 0 / .35); padding: 6px; min-width: 180px;
|
||||
}
|
||||
.flyout-item {
|
||||
|
||||
Reference in New Issue
Block a user