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:
@@ -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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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);
|
||||||
|
|||||||
Reference in New Issue
Block a user