Add quick-fill table for vehicle extra data

Shows previously-used combinations of lookup nomenclature values above
the Additional Data fields in both the new-vehicle and edit forms.
Clicking a row merges the combination into the current values, so
free-text fields the operator already typed are preserved.

NomenclatureContext now fans extra_combination events out to
subscribers; without that they hit the unknown-object warning and were
dropped, so deletes would not have reached other open tabs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:57:36 +03:00
parent 4b6138f049
commit 67c9531600
3 changed files with 251 additions and 1 deletions
@@ -0,0 +1,129 @@
.extra-combos {
margin-bottom: 16px;
}
.extra-combos h3 {
margin: 0 0 8px 0;
font-size: 12px;
color: #888;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.extra-combos-error {
padding: 6px 10px;
background-color: #fee;
color: #c33;
border: 1px solid #fcc;
border-radius: 6px;
font-size: 12px;
margin-bottom: 8px;
}
.extra-combos-table-container {
max-height: 180px;
overflow-y: auto;
overflow-x: auto;
border: 1px solid #e0e0e0;
border-radius: 6px;
}
.extra-combos-table-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.extra-combos-table-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.extra-combos-table-container::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.extra-combos-table-container::-webkit-scrollbar-thumb:hover {
background: #555;
}
.extra-combos-table {
width: 100%;
border-collapse: collapse;
}
.extra-combos-table thead {
background-color: #f8f9fa;
position: sticky;
top: 0;
z-index: 1;
}
.extra-combos-table th {
padding: 0.4rem 0.75rem;
text-align: left;
font-weight: 600;
color: #495057;
font-size: 0.75rem;
border-bottom: 2px solid #dee2e6;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.extra-combos-th-actions {
width: 36px;
}
.extra-combos-table td {
padding: 0.4rem 0.75rem;
border-bottom: 1px solid #dee2e6;
font-size: 0.85rem;
color: #333;
white-space: nowrap;
}
.extra-combos-row {
cursor: pointer;
transition: background-color 0.15s;
}
.extra-combos-row:hover {
background-color: #f0f0ff;
}
.extra-combos-td-actions {
text-align: center;
}
.extra-combos-delete-btn {
background: none;
border: 1px solid #dee2e6;
padding: 0 0.35rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
line-height: 1.4;
color: #999;
}
.extra-combos-delete-btn:hover {
background-color: #dc3545;
border-color: #dc3545;
color: white;
}
.extra-combos-stale {
color: #999;
font-style: italic;
}
.extra-combos-empty {
text-align: center;
padding: 1rem !important;
color: #999;
font-style: italic;
font-size: 0.85rem;
}
@@ -0,0 +1,121 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNomenclatures } from '../contexts/NomenclatureContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
import api from '../services/api';
import './ExtraCombinationsTable.css';
export default function ExtraCombinationsTable({ nomenclatures, onApply }) {
const { subscribeToUpdates } = useNomenclatures();
const { getDisplayValue } = useNomenclatureData();
const [combinations, setCombinations] = useState([]);
const [error, setError] = useState('');
// subscribeToUpdates is not memoized in NomenclatureContext - hold it in a ref
// so the SSE effect below runs once instead of on every provider render.
const subscribeRef = useRef(subscribeToUpdates);
subscribeRef.current = subscribeToUpdates;
// Only lookup nomenclatures form a combination
const lookupNomenclatures = nomenclatures.filter(n => n.kind === 'lookup');
const load = useCallback(async () => {
try {
const res = await api.get('/api/extra-combinations/');
setCombinations(res.data.results || res.data);
} catch {
setError('Failed to load saved combinations');
}
}, []);
useEffect(() => { load(); }, [load]);
// Realtime: rows are created/bumped by the backend when extra data is saved
useEffect(() => {
const unsubscribe = subscribeRef.current((message) => {
if (message.object !== 'extra_combination') return;
const { operation, data } = message;
setCombinations(prev => {
if (operation === 'delete') {
return prev.filter(c => c.id !== data.id);
}
// insert or update: drop any existing copy, put it on top (most recent first)
return [data, ...prev.filter(c => c.id !== data.id)];
});
});
return unsubscribe;
}, []);
const handleDelete = async (e, id) => {
e.stopPropagation();
if (!window.confirm('Delete this combination?')) return;
setError('');
try {
await api.delete(`/api/extra-combinations/${id}/`);
} catch {
setError('Failed to delete combination');
}
};
if (lookupNomenclatures.length === 0) return null;
return (
<div className="extra-combos">
<h3>Previously Used</h3>
{error && <div className="extra-combos-error">{error}</div>}
<div className="extra-combos-table-container">
<table className="extra-combos-table">
<thead>
<tr>
{lookupNomenclatures.map(nom => (
<th key={nom.code}>{nom.name}</th>
))}
<th className="extra-combos-th-actions"></th>
</tr>
</thead>
<tbody>
{combinations.length === 0 ? (
<tr>
<td colSpan={lookupNomenclatures.length + 1} className="extra-combos-empty">
No saved combinations yet
</td>
</tr>
) : (
combinations.map(combo => (
<tr
key={combo.id}
className="extra-combos-row"
onClick={() => onApply(combo.data)}
title="Click to fill the fields below"
>
{lookupNomenclatures.map(nom => {
const entryId = combo.data[nom.code];
const label = entryId != null ? getDisplayValue(nom.code, entryId) : '';
return (
<td key={nom.code}>
{entryId != null && !label
? <span className="extra-combos-stale">#{entryId}</span>
: label}
</td>
);
})}
<td className="extra-combos-td-actions">
<button
className="extra-combos-delete-btn"
onClick={(e) => handleDelete(e, combo.id)}
title="Delete combination"
type="button"
>
&times;
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -359,7 +359,7 @@ export const NomenclatureProvider = ({ children }) => {
const { object, operation, data } = message; const { object, operation, data } = message;
// Handle nomenclature definition and entry updates - notify all subscribers // Handle nomenclature definition and entry updates - notify all subscribers
if (object === 'nomenclature' || object === 'nomenclature_entry') { if (object === 'nomenclature' || object === 'nomenclature_entry' || object === 'extra_combination') {
subscribersRef.current.forEach(callback => { subscribersRef.current.forEach(callback => {
try { try {
callback(message); callback(message);