From 67c95316002f0886b01a4ad5abda77310f06f129 Mon Sep 17 00:00:00 2001 From: kikimor Date: Fri, 18 Sep 2026 18:57:36 +0300 Subject: [PATCH] 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 --- .../src/components/ExtraCombinationsTable.css | 129 ++++++++++++++++++ .../src/components/ExtraCombinationsTable.jsx | 121 ++++++++++++++++ frontend/src/contexts/NomenclatureContext.jsx | 2 +- 3 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ExtraCombinationsTable.css create mode 100644 frontend/src/components/ExtraCombinationsTable.jsx diff --git a/frontend/src/components/ExtraCombinationsTable.css b/frontend/src/components/ExtraCombinationsTable.css new file mode 100644 index 0000000..41d68c0 --- /dev/null +++ b/frontend/src/components/ExtraCombinationsTable.css @@ -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; +} diff --git a/frontend/src/components/ExtraCombinationsTable.jsx b/frontend/src/components/ExtraCombinationsTable.jsx new file mode 100644 index 0000000..bb4d12c --- /dev/null +++ b/frontend/src/components/ExtraCombinationsTable.jsx @@ -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 ( +
+

Previously Used

+ {error &&
{error}
} +
+ + + + {lookupNomenclatures.map(nom => ( + + ))} + + + + + {combinations.length === 0 ? ( + + + + ) : ( + combinations.map(combo => ( + 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 ( + + ); + })} + + + )) + )} + +
{nom.name}
+ No saved combinations yet +
+ {entryId != null && !label + ? #{entryId} + : label} + + +
+
+
+ ); +} diff --git a/frontend/src/contexts/NomenclatureContext.jsx b/frontend/src/contexts/NomenclatureContext.jsx index 96baecc..2c58919 100644 --- a/frontend/src/contexts/NomenclatureContext.jsx +++ b/frontend/src/contexts/NomenclatureContext.jsx @@ -359,7 +359,7 @@ export const NomenclatureProvider = ({ children }) => { const { object, operation, data } = message; // 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 => { try { callback(message);