added vehicles and vehicles extra, dynamic tables for entities, dynamic dropdowns with overlay for selecting and manage data

This commit is contained in:
2026-02-10 17:32:33 +02:00
parent ed35a90cc0
commit 6a42099169
60 changed files with 4845 additions and 81 deletions
@@ -2,12 +2,14 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
import NomenclatureManager from './NomenclatureManager/NomenclatureManager';
import './Header.css';
function Header() {
const { currentUser, logout } = useAuth();
const navigate = useNavigate();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
const getInitials = (user) => {
if (user.first_name && user.last_name) {
@@ -32,6 +34,13 @@ function Header() {
>
📝
</button>
<button
className="nav-button"
onClick={() => setShowNomenclatureManager(true)}
title="Nomenclatures"
>
📋
</button>
<h1>ScalesApp - Real-time Data Monitor</h1>
</div>
<div className="header-right">
@@ -54,6 +63,10 @@ function Header() {
{showPasswordOverlay && (
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
)}
{showNomenclatureManager && (
<NomenclatureManager onClose={() => setShowNomenclatureManager(false)} />
)}
</>
);
}
+391
View File
@@ -0,0 +1,391 @@
.main {
display: flex;
flex-direction: row;
height: calc(100vh - 74px);
}
/* Left panel - vehicle list */
.main-left {
width: 260px;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
background: #fafafa;
flex-shrink: 0;
}
.vehicle-list-header {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
border-bottom: 1px solid #e0e0e0;
}
.vehicle-add-btn {
padding: 8px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.vehicle-add-btn:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
}
.vehicle-search {
padding: 7px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px;
}
.vehicle-search:focus {
outline: none;
border-color: #667eea;
}
/* New vehicle form */
.vehicle-new-form {
padding: 10px 12px;
border-bottom: 1px solid #e0e0e0;
background: #f0f0ff;
}
.vehicle-new-form input {
width: 100%;
padding: 7px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
box-sizing: border-box;
margin-bottom: 6px;
}
.vehicle-new-form input:focus {
outline: none;
border-color: #667eea;
}
.vehicle-new-actions {
display: flex;
gap: 6px;
}
.vehicle-new-actions button {
flex: 1;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: 1px solid #ccc;
background: white;
color: #333;
transition: background 0.2s;
}
.vehicle-new-actions button:first-child {
background: #667eea;
color: white;
border-color: #667eea;
}
.vehicle-new-actions button:first-child:hover {
background: #5568d3;
}
/* Vehicle list */
.vehicle-list {
flex: 1;
overflow-y: auto;
}
.vehicle-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background 0.15s;
}
.vehicle-list-item:hover {
background: #f0f0ff;
}
.vehicle-list-item--active {
background: #e8eaff;
border-left: 3px solid #667eea;
}
.vehicle-list-number {
font-weight: 600;
font-size: 14px;
color: #333;
}
.vehicle-list-weight {
font-size: 12px;
color: #888;
}
.vehicle-list-empty {
padding: 20px;
text-align: center;
color: #999;
font-size: 13px;
}
/* Right panel */
.main-right {
flex: 1;
overflow-y: auto;
background: white;
}
.main-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-size: 16px;
}
.vehicle-detail {
padding: 24px;
max-width: 700px;
}
.vehicle-detail-title {
margin: 0 0 20px 0;
font-size: 24px;
color: #333;
font-weight: 700;
}
.vehicle-error {
background-color: #fee;
color: #c33;
padding: 10px 14px;
border-radius: 6px;
border: 1px solid #fcc;
font-size: 13px;
margin-bottom: 14px;
}
/* Current reading */
.reading-display {
margin-bottom: 20px;
padding: 16px;
background: #f8f8fc;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.reading-display label {
display: block;
font-size: 12px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.reading-value {
font-size: 32px;
font-weight: 700;
color: #667eea;
font-family: 'Courier New', monospace;
}
.reading-value--disconnected {
color: #ccc;
font-size: 18px;
}
/* Weighing cards */
.weighing-section {
display: flex;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.weight-card {
flex: 1;
min-width: 160px;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: white;
}
.weight-card--net {
background: #f0fff0;
border-color: #b2dfb2;
}
.weight-card-header {
font-size: 12px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.weight-card--net .weight-card-header {
color: #2e7d32;
}
.weight-card-value {
font-size: 22px;
font-weight: 700;
color: #333;
margin-bottom: 4px;
font-family: 'Courier New', monospace;
}
.weight-card--net .weight-card-value {
color: #2e7d32;
}
.weight-card-meta {
font-size: 11px;
color: #999;
margin-bottom: 10px;
}
.weight-set-btn {
width: 100%;
padding: 8px 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
margin-top: 6px;
}
.weight-set-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
}
.weight-set-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Extra fields section */
.extra-section {
border-top: 1px solid #e0e0e0;
padding-top: 20px;
}
.extra-section h3 {
margin: 0 0 14px 0;
font-size: 14px;
color: #667eea;
font-weight: 600;
}
.extra-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 14px;
}
.extra-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.extra-field label {
font-size: 12px;
font-weight: 600;
color: #555;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.extra-field input[type="text"],
.extra-field input[type="number"],
.extra-field select {
padding: 7px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.extra-field input:focus,
.extra-field select:focus {
outline: none;
border-color: #667eea;
}
.extra-field input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.extra-save-btn {
padding: 8px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.extra-save-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
}
.extra-save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.main {
flex-direction: column;
height: auto;
}
.main-left {
width: 100%;
max-height: 250px;
border-right: none;
border-bottom: 1px solid #e0e0e0;
}
.weighing-section {
flex-direction: column;
}
.extra-fields {
grid-template-columns: 1fr;
}
}
-35
View File
@@ -1,35 +0,0 @@
import Header from './Header';
import DataDisplay from './DataDisplay';
import useSerialData from '../hooks/useSerialData';
import { useNomenclatures } from '../contexts/NomenclatureContext';
export default function Main() {
const { readings, isConnected, error } = useSerialData();
const { vehicles } = useNomenclatures();
console.log('Vehicles:', vehicles);
return (
<div className="main">
<div>
<div className="vehicles">
{ vehicles.map(vehicle => {
return (
<div key={vehicle.id} className="vehicle-card">
<h3>{vehicle.vehicle_number}</h3>
</div>
);
})}
</div>
</div>
<div className="vehicles-data">
<div className="data-header">
<h2>Vehicle Data</h2>
</div>
<div className="data-list">
<Header />
<DataDisplay readings={readings}/>
</div>
</div>
</div>
);
}
+308
View File
@@ -0,0 +1,308 @@
import { useState, useEffect, useMemo } from 'react';
import Header from './Header';
import useSerialData from '../hooks/useSerialData';
import { useNomenclatures } from '../contexts/NomenclatureContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
import api from '../services/api';
import './Main.css';
export default function Main() {
const { readings, isConnected } = useSerialData();
const { vehicles } = useNomenclatures();
const [selectedVehicleId, setSelectedVehicleId] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [newVehicleNumber, setNewVehicleNumber] = useState('');
const [showNewForm, setShowNewForm] = useState(false);
const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false);
// Nomenclature data from context
const { definitions } = useNomenclatureData();
const vehicleNomenclatures = useMemo(
() => Object.values(definitions)
.filter(d => d.applies_to === 'vehicle')
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)),
[definitions]
);
const [extraData, setExtraData] = useState({});
// Current reading from COM port
// Data format: "Value: 36106.04" or just "36106.04"
const currentReading = readings.length > 0 ? readings[0].data : null;
const currentWeight = useMemo(() => {
if (!currentReading) return null;
// Extract numeric value - handle "Value: 123.45" or just "123.45"
const match = currentReading.match(/[\d.]+/);
if (match) {
const val = parseFloat(match[0]);
return isNaN(val) ? null : Math.round(val);
}
return null;
}, [currentReading]);
// Selected vehicle object (refreshes when vehicles list updates via SSE)
const selectedVehicle = useMemo(
() => vehicles.find(v => v.id === selectedVehicleId) || null,
[vehicles, selectedVehicleId]
);
// Filtered vehicles
const filteredVehicles = useMemo(() => {
if (!searchQuery.trim()) return vehicles;
const q = searchQuery.toLowerCase();
return vehicles.filter(v => v.vehicle_number.toLowerCase().includes(q));
}, [vehicles, searchQuery]);
// When vehicle selection changes, load its extra data
useEffect(() => {
if (selectedVehicle?.extra?.data) {
setExtraData({ ...selectedVehicle.extra.data });
} else {
setExtraData({});
}
}, [selectedVehicle]);
// Create new vehicle
const handleCreateVehicle = async () => {
if (!newVehicleNumber.trim()) return;
setIsSaving(true);
setError('');
try {
const res = await api.post('/api/vehicles/', { vehicle_number: newVehicleNumber.trim() });
setSelectedVehicleId(res.data.id);
setNewVehicleNumber('');
setShowNewForm(false);
} catch (err) {
const msg = err.response?.data?.vehicle_number?.[0] || err.response?.data?.detail || 'Failed to create vehicle';
setError(msg);
} finally {
setIsSaving(false);
}
};
// Set tare
const handleSetTare = async () => {
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
setError('');
try {
await api.post(`/api/vehicles/${selectedVehicle.id}/set-tare/`, { value: currentWeight });
} catch {
setError('Failed to set tare');
}
};
// Set gross
const handleSetGross = async () => {
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
setError('');
try {
await api.post(`/api/vehicles/${selectedVehicle.id}/set-gross/`, { value: currentWeight });
} catch {
setError('Failed to set gross');
}
};
// Save extra data
const handleSaveExtra = async () => {
if (!selectedVehicle) return;
setIsSaving(true);
setError('');
try {
await api.patch(`/api/vehicles/${selectedVehicle.id}/`, {
extra: { data: extraData },
});
} catch (err) {
const data = err.response?.data;
setError(typeof data === 'string' ? data : JSON.stringify(data) || 'Failed to save');
} finally {
setIsSaving(false);
}
};
// Format date
const formatDate = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
// Net weight
const netWeight = (selectedVehicle?.tare != null && selectedVehicle?.gross != null)
? selectedVehicle.gross - selectedVehicle.tare
: null;
return (
<div>
<Header />
<div className="main">
{/* Left panel - Vehicle list */}
<div className="main-left">
<div className="vehicle-list-header">
<button
className="vehicle-add-btn"
onClick={() => { setShowNewForm(true); setError(''); }}
>
+ New Vehicle
</button>
<input
className="vehicle-search"
type="text"
placeholder="Search..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
{showNewForm && (
<div className="vehicle-new-form">
<input
type="text"
placeholder="Vehicle number"
value={newVehicleNumber}
onChange={e => setNewVehicleNumber(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateVehicle()}
disabled={isSaving}
autoFocus
/>
<div className="vehicle-new-actions">
<button onClick={handleCreateVehicle} disabled={isSaving}>
{isSaving ? '...' : 'Create'}
</button>
<button onClick={() => { setShowNewForm(false); setNewVehicleNumber(''); setError(''); }}>
Cancel
</button>
</div>
</div>
)}
<div className="vehicle-list">
{filteredVehicles.map(v => (
<div
key={v.id}
className={`vehicle-list-item ${selectedVehicleId === v.id ? 'vehicle-list-item--active' : ''}`}
onClick={() => { setSelectedVehicleId(v.id); setError(''); }}
>
<span className="vehicle-list-number">{v.vehicle_number}</span>
{v.tare != null && (
<span className="vehicle-list-weight">{v.tare} kg</span>
)}
</div>
))}
{filteredVehicles.length === 0 && (
<div className="vehicle-list-empty">No vehicles found</div>
)}
</div>
</div>
{/* Right panel - Vehicle detail */}
<div className="main-right">
{!selectedVehicle ? (
<div className="main-placeholder">
Select a vehicle or create a new one
</div>
) : (
<div className="vehicle-detail">
{error && <div className="vehicle-error">{error}</div>}
<h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2>
{/* Current reading */}
<div className="reading-display">
<label>Current Reading</label>
<div className={`reading-value ${isConnected ? '' : 'reading-value--disconnected'}`}>
{currentWeight !== null && !isNaN(currentWeight)
? `${currentWeight} kg`
: (isConnected ? 'Waiting...' : 'Disconnected')
}
</div>
</div>
{/* Weighing section */}
<div className="weighing-section">
{/* Tare */}
<div className="weight-card">
<div className="weight-card-header">Tare</div>
<div className="weight-card-value">
{selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '---'}
</div>
{selectedVehicle.tare_date && (
<div className="weight-card-meta">
{formatDate(selectedVehicle.tare_date)}
{selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`}
</div>
)}
<button
className="weight-set-btn"
onClick={handleSetTare}
disabled={currentWeight === null || isNaN(currentWeight)}
>
Set Tare
</button>
</div>
{/* Gross */}
<div className="weight-card">
<div className="weight-card-header">Gross</div>
<div className="weight-card-value">
{selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '---'}
</div>
{selectedVehicle.gross_date && (
<div className="weight-card-meta">
{formatDate(selectedVehicle.gross_date)}
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
</div>
)}
<button
className="weight-set-btn"
onClick={handleSetGross}
disabled={currentWeight === null || isNaN(currentWeight)}
>
Set Gross
</button>
</div>
{/* Net */}
{netWeight !== null && (
<div className="weight-card weight-card--net">
<div className="weight-card-header">Net</div>
<div className="weight-card-value">{netWeight} kg</div>
</div>
)}
</div>
{/* Extra fields from nomenclatures */}
{vehicleNomenclatures.length > 0 && (
<div className="extra-section">
<h3>Additional Data</h3>
<div className="extra-fields">
{vehicleNomenclatures.map(nom => (
<div key={nom.code} className="extra-field">
<NomenclatureDropdown
nomenclatureCode={nom.code}
value={extraData[nom.code]}
onChange={val => setExtraData(prev => ({
...prev,
[nom.code]: val,
}))}
/>
</div>
))}
</div>
<button
className="extra-save-btn"
onClick={handleSaveExtra}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save Extra Data'}
</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,616 @@
/* Overlay backdrop */
.nm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
animation: nmFadeIn 0.2s ease-in;
}
@keyframes nmFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Main content container */
.nm-content {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 960px;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: nmSlideUp 0.3s ease-out;
}
@keyframes nmSlideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Header */
.nm-header {
padding: 20px 24px;
border-bottom: 2px solid #667eea;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.nm-header h2 {
margin: 0;
color: #667eea;
font-size: 22px;
}
.nm-close-button {
background: none;
border: none;
font-size: 32px;
color: #999;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.nm-close-button:hover {
background: #f5f5f5;
color: #333;
}
/* Body - two panels */
.nm-body {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* Left panel */
.nm-list-panel {
width: 250px;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.nm-add-btn {
margin: 12px;
padding: 8px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.nm-add-btn:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
}
.nm-list {
flex: 1;
overflow-y: auto;
}
.nm-list-item {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background 0.15s;
}
.nm-list-item:hover {
background: #f5f5ff;
}
.nm-list-item--active {
background: #eef0ff;
border-left: 3px solid #667eea;
}
.nm-list-item-info {
flex: 1;
min-width: 0;
}
.nm-list-item-name {
display: block;
font-weight: 600;
font-size: 14px;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nm-list-item-meta {
display: block;
font-size: 11px;
color: #999;
margin-top: 2px;
}
.nm-edit-btn {
background: none;
border: none;
color: #999;
font-size: 16px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
flex-shrink: 0;
}
.nm-edit-btn:hover {
color: #667eea;
background: #f0f0ff;
}
.nm-loading {
padding: 20px;
text-align: center;
color: #999;
font-size: 14px;
}
.nm-empty {
padding: 16px;
text-align: center;
color: #999;
font-size: 13px;
}
/* Right panel */
.nm-form-panel {
flex: 1;
overflow-y: auto;
min-width: 0;
}
.nm-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-size: 15px;
padding: 40px;
text-align: center;
}
.nm-form {
padding: 20px 24px;
}
.nm-form-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.nm-error {
background-color: #fee;
color: #c33;
padding: 10px 14px;
border-radius: 6px;
border: 1px solid #fcc;
font-size: 13px;
margin-bottom: 14px;
}
/* Form grid */
.nm-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 20px;
}
.nm-form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.nm-form-group label {
font-weight: 600;
color: #555;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nm-form-group input[type="text"],
.nm-form-group input[type="number"],
.nm-form-group select {
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.nm-form-group input:focus,
.nm-form-group select:focus {
outline: none;
border-color: #667eea;
}
.nm-form-group input:disabled,
.nm-form-group select:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
/* Sections */
.nm-section {
margin-bottom: 20px;
}
.nm-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #e0e0e0;
}
.nm-section-header h3 {
margin: 0;
font-size: 14px;
color: #667eea;
font-weight: 600;
}
.nm-section-add {
background: none;
border: 1px solid #667eea;
color: #667eea;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.nm-section-add:hover {
background: #667eea;
color: white;
}
.nm-section-add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Fields table */
.nm-fields-table {
border: 1px solid #e0e0e0;
border-radius: 6px;
overflow: hidden;
}
.nm-fields-header {
display: grid;
grid-template-columns: 2fr 2fr 1.2fr 40px 32px;
gap: 6px;
padding: 6px 8px;
background: #f8f8fc;
font-size: 11px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nm-fields-row {
display: grid;
grid-template-columns: 2fr 2fr 1.2fr 40px 32px;
gap: 6px;
padding: 6px 8px;
border-top: 1px solid #f0f0f0;
align-items: center;
}
.nm-fields-row input[type="text"] {
padding: 5px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
width: 100%;
box-sizing: border-box;
}
.nm-fields-row input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
.nm-fields-row select {
padding: 5px 4px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
width: 100%;
box-sizing: border-box;
}
.nm-fields-row select:focus {
outline: none;
border-color: #667eea;
}
.nm-fields-row input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
justify-self: center;
}
.nm-remove-btn {
background: none;
border: none;
color: #ccc;
font-size: 20px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
}
.nm-remove-btn:hover:not(:disabled) {
color: #c33;
background: #fee;
}
.nm-remove-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Entries */
.nm-entries {
display: flex;
flex-direction: column;
gap: 8px;
}
.nm-entry {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 10px 12px;
display: flex;
align-items: flex-start;
gap: 10px;
}
.nm-entry--inactive {
opacity: 0.5;
background: #fafafa;
}
.nm-entry-fields {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.nm-entry-field {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 100px;
flex: 1;
}
.nm-entry-field label {
font-size: 11px;
color: #888;
font-weight: 500;
}
.nm-entry-field input[type="text"],
.nm-entry-field input[type="number"],
.nm-entry-field select {
padding: 5px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
width: 100%;
box-sizing: border-box;
}
.nm-entry-field input:focus,
.nm-entry-field select:focus {
outline: none;
border-color: #667eea;
}
.nm-entry-field input[type="checkbox"] {
width: 16px;
height: 16px;
margin-top: 4px;
}
.nm-entry-actions {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.nm-entry-save {
background: #667eea;
color: white;
border: none;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.nm-entry-save:hover {
background: #5568d3;
}
.nm-entry-toggle {
background: #e8f5e9;
color: #2e7d32;
border: none;
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.nm-entry-toggle:hover {
background: #c8e6c9;
}
.nm-entry-toggle--inactive {
background: #fff3e0;
color: #e65100;
}
.nm-entry-toggle--inactive:hover {
background: #ffe0b2;
}
/* Form actions */
.nm-form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #e0e0e0;
}
.nm-form-actions-right {
display: flex;
gap: 10px;
margin-left: auto;
}
.nm-cancel-btn {
padding: 8px 20px;
background: white;
border: 1px solid #ddd;
color: #666;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
}
.nm-cancel-btn:hover:not(:disabled) {
border-color: #999;
color: #333;
}
.nm-save-btn {
padding: 8px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.nm-save-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
}
.nm-save-btn:disabled,
.nm-cancel-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.nm-delete-btn {
padding: 8px 16px;
background: white;
border: 1px solid #e57373;
color: #c33;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.nm-delete-btn:hover:not(:disabled) {
background: #fee;
}
.nm-delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.nm-content {
max-width: 100%;
max-height: 100vh;
border-radius: 0;
}
.nm-body {
flex-direction: column;
}
.nm-list-panel {
width: 100%;
max-height: 200px;
border-right: none;
border-bottom: 1px solid #e0e0e0;
}
.nm-form-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,392 @@
import React, { useState, useEffect, useCallback } from 'react';
import api from '../../services/api';
import './NomenclatureManager.css';
const EMPTY_FORM = {
code: '',
name: '',
applies_to: 'vehicle',
kind: 'lookup',
display_field: 'name',
sort_order: 0,
fields: [{ key: 'name', label: 'Name', field_type: 'text', required: true, choices: null }],
};
function NomenclatureManager({ onClose }) {
const [nomenclatures, setNomenclatures] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [form, setForm] = useState(null); // null = nothing selected
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
// Load nomenclature list
const loadNomenclatures = useCallback(async () => {
setIsLoading(true);
try {
const res = await api.get('/api/nomenclatures/');
const data = res.data.results || res.data;
setNomenclatures(Array.isArray(data) ? data : []);
} catch (err) {
setError('Failed to load nomenclatures');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadNomenclatures();
}, [loadNomenclatures]);
// Select a nomenclature for editing
const handleSelect = (nom) => {
setSelectedId(nom.id);
setError('');
setForm({
code: nom.code,
name: nom.name,
applies_to: nom.applies_to,
kind: nom.kind,
display_field: nom.display_field || 'name',
sort_order: nom.sort_order || 0,
fields: (nom.fields || []).map(f => ({
key: f.key,
label: f.label || '',
field_type: f.field_type,
required: f.required || false,
choices: f.choices || null,
})),
});
};
// Start adding new nomenclature
const handleAdd = () => {
setSelectedId(null);
setError('');
setForm({ ...EMPTY_FORM, fields: [{ ...EMPTY_FORM.fields[0] }] });
};
// Cancel editing
const handleCancel = () => {
setForm(null);
setSelectedId(null);
setError('');
};
// Save nomenclature (create or update)
const handleSave = async () => {
if (!form) return;
setIsSaving(true);
setError('');
const payload = {
code: form.code,
name: form.name,
applies_to: form.applies_to,
kind: form.kind,
display_field: form.display_field,
sort_order: form.sort_order,
fields: form.fields.map(f => ({
key: f.key,
label: f.label,
field_type: f.field_type,
required: f.required,
choices: f.field_type === 'choice' ? f.choices : null,
})),
};
try {
if (selectedId) {
await api.put(`/api/nomenclatures/${selectedId}/`, payload);
} else {
const res = await api.post('/api/nomenclatures/', payload);
setSelectedId(res.data.id);
}
await loadNomenclatures();
setError('');
} catch (err) {
const data = err.response?.data;
if (typeof data === 'string') {
setError(data);
} else if (data) {
const messages = [];
for (const [key, val] of Object.entries(data)) {
if (Array.isArray(val)) messages.push(`${key}: ${val.join(', ')}`);
else if (typeof val === 'string') messages.push(`${key}: ${val}`);
else messages.push(`${key}: ${JSON.stringify(val)}`);
}
setError(messages.join('; ') || 'Failed to save');
} else {
setError('Failed to save nomenclature');
}
} finally {
setIsSaving(false);
}
};
// Delete nomenclature
const handleDelete = async () => {
if (!selectedId) return;
if (!window.confirm('Delete this nomenclature and all its entries?')) return;
try {
await api.delete(`/api/nomenclatures/${selectedId}/`);
setForm(null);
setSelectedId(null);
await loadNomenclatures();
} catch {
setError('Failed to delete nomenclature');
}
};
// --- Field management ---
const addField = () => {
setForm(prev => ({
...prev,
fields: [...prev.fields, { key: '', label: '', field_type: 'text', required: false, choices: null }],
}));
};
const updateField = (index, key, value) => {
setForm(prev => ({
...prev,
fields: prev.fields.map((f, i) => i === index ? { ...f, [key]: value } : f),
}));
};
const removeField = (index) => {
setForm(prev => ({
...prev,
fields: prev.fields.filter((_, i) => i !== index),
}));
};
const handleOverlayClick = (e) => {
if (e.target.className === 'nm-overlay') {
onClose();
}
};
return (
<div className="nm-overlay" onClick={handleOverlayClick}>
<div className="nm-content">
{/* Header */}
<div className="nm-header">
<h2>Nomenclatures</h2>
<button className="nm-close-button" onClick={onClose}>&times;</button>
</div>
<div className="nm-body">
{/* Left panel - List */}
<div className="nm-list-panel">
<button className="nm-add-btn" onClick={handleAdd}>+ Add New</button>
{isLoading && <div className="nm-loading">Loading...</div>}
<div className="nm-list">
{nomenclatures.map(nom => (
<div
key={nom.id}
className={`nm-list-item ${selectedId === nom.id ? 'nm-list-item--active' : ''}`}
onClick={() => handleSelect(nom)}
>
<div className="nm-list-item-info">
<span className="nm-list-item-name">{nom.name}</span>
<span className="nm-list-item-meta">
{nom.kind === 'lookup' ? 'Lookup' : 'Field'} / {nom.applies_to}
</span>
</div>
<button
className="nm-edit-btn"
onClick={(e) => { e.stopPropagation(); handleSelect(nom); }}
title="Edit"
>
&#9998;
</button>
</div>
))}
{!isLoading && nomenclatures.length === 0 && (
<div className="nm-empty">No nomenclatures yet</div>
)}
</div>
</div>
{/* Right panel - Form */}
<div className="nm-form-panel">
{!form ? (
<div className="nm-placeholder">
Select a nomenclature to edit or click "+ Add New"
</div>
) : (
<div className="nm-form">
{error && <div className="nm-error">{error}</div>}
<div className="nm-form-title">
{selectedId ? 'Edit Nomenclature' : 'New Nomenclature'}
</div>
{/* Basic fields */}
<div className="nm-form-grid">
<div className="nm-form-group">
<label>Code</label>
<input
type="text"
value={form.code}
onChange={e => setForm(prev => ({ ...prev, code: e.target.value }))}
placeholder="e.g. cargo_type"
disabled={isSaving}
/>
</div>
<div className="nm-form-group">
<label>Name</label>
<input
type="text"
value={form.name}
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
placeholder="e.g. Cargo Type"
disabled={isSaving}
/>
</div>
<div className="nm-form-group">
<label>Applies to</label>
<select
value={form.applies_to}
onChange={e => setForm(prev => ({ ...prev, applies_to: e.target.value }))}
disabled={isSaving}
>
<option value="vehicle">Vehicle</option>
<option value="container">Container</option>
</select>
</div>
<div className="nm-form-group">
<label>Kind</label>
<select
value={form.kind}
onChange={e => setForm(prev => ({ ...prev, kind: e.target.value }))}
disabled={isSaving}
>
<option value="lookup">Lookup Table</option>
<option value="field">Custom Field</option>
</select>
</div>
{form.kind === 'lookup' && (
<div className="nm-form-group">
<label>Display Field</label>
<select
value={form.display_field}
onChange={e => setForm(prev => ({ ...prev, display_field: e.target.value }))}
disabled={isSaving}
>
{form.fields.filter(f => f.key).map(f => (
<option key={f.key} value={f.key}>{f.key}</option>
))}
</select>
</div>
)}
</div>
{/* Fields section */}
<div className="nm-section">
<div className="nm-section-header">
<h3>Fields</h3>
<button className="nm-section-add" onClick={addField} disabled={isSaving}>
+ Add Field
</button>
</div>
<div className="nm-fields-table">
<div className="nm-fields-header">
<span>Key</span>
<span>Label</span>
<span>Type</span>
<span>Req</span>
<span></span>
</div>
{form.fields.map((field, idx) => (
<div key={idx} className="nm-fields-row">
<input
type="text"
value={field.key}
onChange={e => updateField(idx, 'key', e.target.value)}
placeholder="key"
disabled={isSaving}
/>
<input
type="text"
value={field.label}
onChange={e => updateField(idx, 'label', e.target.value)}
placeholder="Label"
disabled={isSaving}
/>
<select
value={field.field_type}
onChange={e => updateField(idx, 'field_type', e.target.value)}
disabled={isSaving}
>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="bool">Boolean</option>
<option value="choice">Choice</option>
</select>
<input
type="checkbox"
checked={field.required}
onChange={e => updateField(idx, 'required', e.target.checked)}
disabled={isSaving}
/>
<button
className="nm-remove-btn"
onClick={() => removeField(idx)}
disabled={isSaving || form.fields.length <= 1}
title="Remove field"
>
&times;
</button>
</div>
))}
</div>
</div>
{/* Actions */}
<div className="nm-form-actions">
{selectedId && (
<button
className="nm-delete-btn"
onClick={handleDelete}
disabled={isSaving}
>
Delete
</button>
)}
<div className="nm-form-actions-right">
<button
className="nm-cancel-btn"
onClick={handleCancel}
disabled={isSaving}
>
Cancel
</button>
<button
className="nm-save-btn"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default NomenclatureManager;
@@ -0,0 +1,80 @@
.nui-form-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 13000;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
animation: nuiFormFadeIn 0.15s ease-out;
}
@keyframes nuiFormFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.nui-form-modal {
width: 90%;
max-width: 600px;
max-height: 80vh;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
animation: nuiFormSlideUp 0.15s ease-out;
}
@keyframes nuiFormSlideUp {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.nui-form-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #eee;
background-color: #f8f9fa;
border-radius: 8px 8px 0 0;
flex-shrink: 0;
}
.nui-form-modal-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #333;
}
.nui-form-modal-close {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
font-size: 1.4rem;
color: #666;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
line-height: 1;
}
.nui-form-modal-close:hover {
background-color: #e9ecef;
color: #333;
}
.nui-form-modal-content {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import './FormOverlay.css';
export default function FormOverlay({ isOpen, onClose, title, children }) {
const overlayRef = useRef(null);
const mouseDownTargetRef = useRef(null);
// Escape key to close
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// Prevent body scroll when open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}
}, [isOpen]);
if (!isOpen) return null;
// Smart click handling: only close if mousedown AND mouseup both on backdrop
// This prevents closing when user selects text and drags to backdrop
const handleMouseDown = (e) => {
mouseDownTargetRef.current = e.target;
};
const handleMouseUp = (e) => {
if (mouseDownTargetRef.current === overlayRef.current && e.target === overlayRef.current) {
onClose();
}
mouseDownTargetRef.current = null;
};
return createPortal(
<div
className="nui-form-overlay"
ref={overlayRef}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<div className="nui-form-modal" onClick={(e) => e.stopPropagation()}>
<div className="nui-form-modal-header">
<h3>{title}</h3>
<button
className="nui-form-modal-close"
onClick={onClose}
type="button"
>
&times;
</button>
</div>
<div className="nui-form-modal-content">
{children}
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,118 @@
.nui-dropdown {
display: flex;
flex-direction: column;
gap: 4px;
}
.nui-dropdown-label {
font-size: 12px;
font-weight: 600;
color: #555;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nui-dropdown-label--checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
text-transform: uppercase;
}
.nui-dropdown-row {
display: flex;
gap: 4px;
}
.nui-dropdown-select {
flex: 1;
padding: 7px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
background-color: white;
color: #333;
transition: border-color 0.2s;
min-width: 0;
}
.nui-dropdown-select:focus {
outline: none;
border-color: #667eea;
}
.nui-dropdown-select:disabled {
background-color: #f0f0f0;
cursor: not-allowed;
opacity: 0.6;
}
.nui-dropdown-input {
width: 100%;
padding: 7px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
background-color: white;
color: #333;
transition: border-color 0.2s;
box-sizing: border-box;
}
.nui-dropdown-input:focus {
outline: none;
border-color: #667eea;
}
.nui-dropdown-input:disabled {
background-color: #f0f0f0;
cursor: not-allowed;
opacity: 0.6;
}
/* Hide number spinner */
.nui-dropdown-input[type="number"] {
-moz-appearance: textfield;
}
.nui-dropdown-input[type="number"]::-webkit-outer-spin-button,
.nui-dropdown-input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.nui-dropdown-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
}
.nui-dropdown-manage-btn {
width: 34px;
height: 34px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 18px;
color: #666;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
line-height: 1;
letter-spacing: 1px;
}
.nui-dropdown-manage-btn:hover:not(:disabled) {
border-color: #667eea;
color: #667eea;
background-color: #f0f0ff;
}
.nui-dropdown-manage-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@@ -0,0 +1,158 @@
import { useState, useMemo } from 'react';
import { useNomenclatureData } from '../../contexts/NomenclatureDataContext';
import NomenclatureManagementOverlay from './NomenclatureManagementOverlay';
import './NomenclatureDropdown.css';
export default function NomenclatureDropdown({
nomenclatureCode,
value,
onChange,
label,
disabled = false,
}) {
const { definitions, getActiveEntries, isLoading } = useNomenclatureData();
const [showOverlay, setShowOverlay] = useState(false);
const nomenclature = definitions[nomenclatureCode];
const activeEntries = useMemo(
() => getActiveEntries(nomenclatureCode),
[getActiveEntries, nomenclatureCode]
);
if (!nomenclature) {
if (isLoading) {
return (
<div className="nui-dropdown">
{label && <label className="nui-dropdown-label">{label}</label>}
<select disabled className="nui-dropdown-select">
<option>Loading...</option>
</select>
</div>
);
}
return null;
}
const displayLabel = label || nomenclature.name;
// LOOKUP type: select dropdown + manage button
if (nomenclature.kind === 'lookup') {
const handleSelectChange = (e) => {
const val = e.target.value;
onChange(val ? parseInt(val, 10) : undefined);
};
const handleOverlaySelect = (entryId) => {
onChange(entryId);
setShowOverlay(false);
};
return (
<div className="nui-dropdown">
<label className="nui-dropdown-label">{displayLabel}</label>
<div className="nui-dropdown-row">
<select
className="nui-dropdown-select"
value={value || ''}
onChange={handleSelectChange}
disabled={disabled}
>
<option value="">-- Select --</option>
{activeEntries.map(entry => (
<option key={entry.id} value={entry.id}>
{entry.display_value}
</option>
))}
</select>
<button
className="nui-dropdown-manage-btn"
onClick={() => setShowOverlay(true)}
disabled={disabled}
type="button"
title={`Manage ${displayLabel}`}
>
&#8230;
</button>
</div>
<NomenclatureManagementOverlay
nomenclatureCode={nomenclatureCode}
isOpen={showOverlay}
onClose={() => setShowOverlay(false)}
mode="select"
initialSelection={value || null}
onSelect={handleOverlaySelect}
/>
</div>
);
}
// FIELD type: render appropriate input
const fieldDef = nomenclature.fields?.[0];
if (!fieldDef) return null;
if (fieldDef.field_type === 'bool') {
return (
<div className="nui-dropdown">
<label className="nui-dropdown-label nui-dropdown-label--checkbox">
<input
type="checkbox"
checked={value || false}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="nui-dropdown-checkbox"
/>
{displayLabel}
</label>
</div>
);
}
if (fieldDef.field_type === 'number') {
return (
<div className="nui-dropdown">
<label className="nui-dropdown-label">{displayLabel}</label>
<input
type="number"
className="nui-dropdown-input"
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
disabled={disabled}
/>
</div>
);
}
if (fieldDef.field_type === 'choice' && fieldDef.choices) {
return (
<div className="nui-dropdown">
<label className="nui-dropdown-label">{displayLabel}</label>
<select
className="nui-dropdown-select"
value={value || ''}
onChange={(e) => onChange(e.target.value || undefined)}
disabled={disabled}
>
<option value="">-- Select --</option>
{fieldDef.choices.map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
);
}
// Default: text input
return (
<div className="nui-dropdown">
<label className="nui-dropdown-label">{displayLabel}</label>
<input
type="text"
className="nui-dropdown-input"
value={value || ''}
onChange={(e) => onChange(e.target.value || undefined)}
disabled={disabled}
/>
</div>
);
}
@@ -0,0 +1,139 @@
.nui-entry-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.nui-entry-form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.nui-entry-form-field > label {
font-weight: 500;
color: #333;
font-size: 0.9rem;
}
.nui-entry-form-field input[type="text"],
.nui-entry-form-field input[type="number"],
.nui-entry-form-field select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95rem;
background-color: white;
color: #333;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.nui-entry-form-field input[type="text"]:focus,
.nui-entry-form-field input[type="number"]:focus,
.nui-entry-form-field select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.nui-entry-form-field input[type="text"]:disabled,
.nui-entry-form-field input[type="number"]:disabled,
.nui-entry-form-field select:disabled {
background-color: #f0f0f0;
cursor: not-allowed;
opacity: 0.6;
}
/* Hide number spinner */
.nui-entry-form-field input[type="number"] {
-moz-appearance: textfield;
}
.nui-entry-form-field input[type="number"]::-webkit-outer-spin-button,
.nui-entry-form-field input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Error state */
.nui-entry-form-input-error {
border-color: #dc3545 !important;
}
.nui-entry-form-error {
font-size: 0.8rem;
color: #dc3545;
}
/* Checkbox field */
.nui-entry-form-checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: normal !important;
font-size: 0.95rem;
color: #333;
}
.nui-entry-form-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Actions */
.nui-entry-form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.nui-entry-form-cancel {
background-color: #6c757d;
color: white;
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.nui-entry-form-cancel:hover {
background-color: #5a6268;
}
.nui-entry-form-cancel:active {
transform: scale(0.98);
}
.nui-entry-form-save {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.1s;
}
.nui-entry-form-save:hover:not(:disabled) {
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
}
.nui-entry-form-save:active:not(:disabled) {
transform: scale(0.98);
}
.nui-entry-form-save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@@ -0,0 +1,171 @@
import { useState, useEffect, useRef } from 'react';
import './NomenclatureEntryForm.css';
// Auto-select text on focus (PF99 pattern)
const handleAutoSelect = (e) => e.target.select();
const preventDrag = (e) => e.preventDefault();
export default function NomenclatureEntryForm({
fields = [],
mode = 'add',
initialData = null,
onSave,
onCancel,
isSaving = false,
}) {
const firstInputRef = useRef(null);
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
// Initialize form data
useEffect(() => {
const data = {};
const sortedFields = [...fields].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
for (const field of sortedFields) {
if (mode === 'edit' && initialData) {
data[field.key] = initialData[field.key] ?? getDefaultValue(field.field_type);
} else {
data[field.key] = getDefaultValue(field.field_type);
}
}
setFormData(data);
setErrors({});
// Auto-focus first input
setTimeout(() => firstInputRef.current?.focus(), 50);
}, [fields, mode, initialData]);
const getDefaultValue = (fieldType) => {
switch (fieldType) {
case 'bool': return false;
case 'number': return '';
case 'choice': return '';
default: return '';
}
};
const handleChange = (key, value) => {
setFormData(prev => ({ ...prev, [key]: value }));
// Clear error on change
if (errors[key]) {
setErrors(prev => ({ ...prev, [key]: null }));
}
};
const validate = () => {
const newErrors = {};
const sortedFields = [...fields].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
for (const field of sortedFields) {
if (field.required) {
const val = formData[field.key];
if (field.field_type === 'bool') continue; // booleans are always valid
if (val === '' || val === null || val === undefined) {
newErrors[field.key] = `${field.label || field.key} is required`;
}
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (!validate()) return;
// Convert types before saving
const cleanData = {};
for (const field of fields) {
let val = formData[field.key];
if (field.field_type === 'number' && val !== '' && val !== undefined) {
val = Number(val);
}
cleanData[field.key] = val;
}
onSave(cleanData);
};
const sortedFields = [...fields].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
return (
<form className="nui-entry-form" onSubmit={handleSubmit}>
{sortedFields.map((field, idx) => (
<div key={field.key} className="nui-entry-form-field">
<label>{field.label || field.key}{field.required ? ' *' : ''}</label>
{field.field_type === 'bool' ? (
<label className="nui-entry-form-checkbox-label">
<input
ref={idx === 0 ? firstInputRef : undefined}
type="checkbox"
checked={formData[field.key] || false}
onChange={(e) => handleChange(field.key, e.target.checked)}
disabled={isSaving}
className="nui-entry-form-checkbox"
/>
<span>{formData[field.key] ? 'Yes' : 'No'}</span>
</label>
) : field.field_type === 'number' ? (
<input
ref={idx === 0 ? firstInputRef : undefined}
type="number"
value={formData[field.key] ?? ''}
onChange={(e) => handleChange(field.key, e.target.value)}
onFocus={handleAutoSelect}
onDragStart={preventDrag}
disabled={isSaving}
className={errors[field.key] ? 'nui-entry-form-input-error' : ''}
/>
) : field.field_type === 'choice' && field.choices ? (
<select
ref={idx === 0 ? firstInputRef : undefined}
value={formData[field.key] || ''}
onChange={(e) => handleChange(field.key, e.target.value)}
disabled={isSaving}
className={errors[field.key] ? 'nui-entry-form-input-error' : ''}
>
<option value="">-- Select --</option>
{field.choices.map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
) : (
<input
ref={idx === 0 ? firstInputRef : undefined}
type="text"
value={formData[field.key] || ''}
onChange={(e) => handleChange(field.key, e.target.value)}
onFocus={handleAutoSelect}
onDragStart={preventDrag}
disabled={isSaving}
className={errors[field.key] ? 'nui-entry-form-input-error' : ''}
/>
)}
{errors[field.key] && (
<span className="nui-entry-form-error">{errors[field.key]}</span>
)}
</div>
))}
<div className="nui-entry-form-actions">
<button
type="button"
className="nui-entry-form-cancel"
onClick={onCancel}
disabled={isSaving}
>
Cancel
</button>
<button
type="submit"
className="nui-entry-form-save"
disabled={isSaving}
>
{isSaving ? 'Saving...' : mode === 'edit' ? 'Update' : 'Save'}
</button>
</div>
</form>
);
}
@@ -0,0 +1,383 @@
.nui-mgmt-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 12000;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
animation: nuiMgmtFadeIn 0.15s ease-out;
}
@keyframes nuiMgmtFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.nui-mgmt-content {
width: 90%;
max-width: 1200px;
max-height: 90vh;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
animation: nuiMgmtSlideUp 0.15s ease-out;
}
@keyframes nuiMgmtSlideUp {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Header */
.nui-mgmt-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #dee2e6;
background-color: #f8f9fa;
border-radius: 8px 8px 0 0;
flex-shrink: 0;
}
.nui-mgmt-header h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: #333;
}
.nui-mgmt-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
font-size: 1.5rem;
color: #666;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
line-height: 1;
}
.nui-mgmt-close:hover {
background-color: #e9ecef;
color: #333;
}
/* Toolbar */
.nui-mgmt-toolbar {
display: flex;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid #dee2e6;
flex-shrink: 0;
}
.nui-mgmt-search {
flex: 1;
padding: 0.5rem 1rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.95rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.nui-mgmt-search:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.nui-mgmt-add-btn {
background-color: #28a745;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
.nui-mgmt-add-btn:hover {
background-color: #218838;
}
/* Error */
.nui-mgmt-error {
margin: 0.5rem 1.5rem;
padding: 0.5rem 1rem;
background-color: #fee;
color: #c33;
border: 1px solid #fcc;
border-radius: 4px;
font-size: 0.85rem;
flex-shrink: 0;
}
/* Table container */
.nui-mgmt-table-container {
flex: 1;
overflow-y: auto;
overflow-x: auto;
}
/* Scrollbar styling */
.nui-mgmt-table-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.nui-mgmt-table-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.nui-mgmt-table-container::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.nui-mgmt-table-container::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Table */
.nui-mgmt-table {
width: 100%;
border-collapse: collapse;
}
.nui-mgmt-table thead {
background-color: #f8f9fa;
position: sticky;
top: 0;
z-index: 1;
}
.nui-mgmt-table th {
padding: 0.6rem 1rem;
text-align: left;
font-weight: 600;
color: #495057;
font-size: 0.85rem;
border-bottom: 2px solid #dee2e6;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.nui-mgmt-th-id {
width: 60px;
text-align: center;
}
.nui-mgmt-th-status {
width: 80px;
text-align: center;
}
.nui-mgmt-th-actions {
width: 50px;
text-align: center;
}
.nui-mgmt-table td {
padding: 0.5rem 1rem;
border-bottom: 1px solid #dee2e6;
font-size: 0.95rem;
color: #333;
}
.nui-mgmt-td-id {
text-align: center;
color: #888;
font-size: 0.85rem;
}
.nui-mgmt-td-status {
text-align: center;
}
.nui-mgmt-td-actions {
text-align: center;
}
/* Row states */
.nui-mgmt-row {
cursor: pointer;
transition: background-color 0.15s;
}
.nui-mgmt-row:hover {
background-color: #f0f0ff;
}
.nui-mgmt-row--selected {
background-color: #e8eaff;
}
.nui-mgmt-row--selected:hover {
background-color: #dcdeff;
}
.nui-mgmt-row--inactive {
opacity: 0.6;
}
.nui-mgmt-row--inactive td {
text-decoration: line-through;
color: #999;
}
.nui-mgmt-row--inactive .nui-mgmt-td-id,
.nui-mgmt-row--inactive .nui-mgmt-td-status,
.nui-mgmt-row--inactive .nui-mgmt-td-actions {
text-decoration: none;
}
/* Active badge */
.nui-mgmt-active-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.nui-mgmt-active-badge--active {
background-color: #d4edda;
color: #155724;
}
.nui-mgmt-active-badge--active:hover {
background-color: #c3e6cb;
}
.nui-mgmt-active-badge--inactive {
background-color: #f8d7da;
color: #721c24;
}
.nui-mgmt-active-badge--inactive:hover {
background-color: #f5c6cb;
}
/* Edit button in row */
.nui-mgmt-edit-btn {
background: none;
border: 1px solid #dee2e6;
padding: 0.2rem 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.85rem;
color: #666;
}
.nui-mgmt-edit-btn:hover {
background-color: #e9ecef;
border-color: #adb5bd;
color: #333;
}
/* Empty state */
.nui-mgmt-empty {
text-align: center;
padding: 2rem !important;
color: #999;
font-style: italic;
}
/* Footer */
.nui-mgmt-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-top: 1px solid #dee2e6;
background-color: #f8f9fa;
border-radius: 0 0 8px 8px;
flex-shrink: 0;
}
.nui-mgmt-footer-info {
font-size: 0.85rem;
color: #666;
}
.nui-mgmt-footer-actions {
display: flex;
gap: 0.5rem;
}
.nui-mgmt-footer-edit-btn {
background: none;
border: 1px solid #667eea;
color: #667eea;
padding: 0.4rem 1rem;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.nui-mgmt-footer-edit-btn:hover {
background-color: #667eea;
color: white;
}
.nui-mgmt-delete-btn {
background: none;
border: 1px solid #dc3545;
color: #dc3545;
padding: 0.4rem 1rem;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.nui-mgmt-delete-btn:hover {
background-color: #dc3545;
color: white;
}
.nui-mgmt-select-btn {
background-color: #667eea;
color: white;
padding: 0.4rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.nui-mgmt-select-btn:hover:not(:disabled) {
background-color: #5568d3;
}
.nui-mgmt-select-btn:disabled {
background-color: #6c757d;
opacity: 0.6;
cursor: not-allowed;
}
@@ -0,0 +1,369 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useNomenclatureData } from '../../contexts/NomenclatureDataContext';
import FormOverlay from './FormOverlay';
import NomenclatureEntryForm from './NomenclatureEntryForm';
import './NomenclatureManagementOverlay.css';
export default function NomenclatureManagementOverlay({
nomenclatureCode,
isOpen,
onClose,
mode = 'manage', // 'manage' or 'select'
initialSelection = null, // entry ID to pre-select
onSelect, // called in 'select' mode when user picks entry
}) {
const {
definitions, entries,
createEntry, updateEntry, deleteEntry, toggleEntryActive,
} = useNomenclatureData();
const nomenclature = definitions[nomenclatureCode];
const allEntries = entries[nomenclatureCode] || [];
const [selectedId, setSelectedId] = useState(initialSelection);
const [searchTerm, setSearchTerm] = useState('');
const [showForm, setShowForm] = useState(false);
const [formMode, setFormMode] = useState('add');
const [editData, setEditData] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
// Track items before form opens to detect new additions
const itemsBeforeFormRef = useRef(new Set());
const selectedRowRef = useRef(null);
const searchInputRef = useRef(null);
const overlayRef = useRef(null);
// Reset state when overlay opens
useEffect(() => {
if (isOpen) {
setSelectedId(initialSelection);
setSearchTerm('');
setShowForm(false);
setError('');
setTimeout(() => searchInputRef.current?.focus(), 100);
}
}, [isOpen, initialSelection]);
// Auto-scroll to selected item
useEffect(() => {
if (selectedRowRef.current) {
selectedRowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [selectedId]);
// Auto-select newly added items after form closes
useEffect(() => {
if (!showForm && itemsBeforeFormRef.current.size > 0) {
const currentIds = new Set(allEntries.map(e => e.id));
for (const id of currentIds) {
if (!itemsBeforeFormRef.current.has(id)) {
setSelectedId(id);
break;
}
}
itemsBeforeFormRef.current = new Set();
}
}, [showForm, allEntries]);
// Fields sorted by sort_order
const sortedFields = useMemo(() => {
if (!nomenclature?.fields) return [];
return [...nomenclature.fields].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
}, [nomenclature]);
// Search fields: all text-type field keys
const searchFieldKeys = useMemo(() => {
return sortedFields
.filter(f => f.field_type === 'text' || f.field_type === 'choice')
.map(f => f.key);
}, [sortedFields]);
// Filtered entries by search
const filteredEntries = useMemo(() => {
if (!searchTerm.trim()) return allEntries;
const q = searchTerm.toLowerCase();
return allEntries.filter(entry => {
return searchFieldKeys.some(key => {
const val = entry.data?.[key];
return val && String(val).toLowerCase().includes(q);
});
});
}, [allEntries, searchTerm, searchFieldKeys]);
// Selected entry object
const selectedEntry = useMemo(
() => allEntries.find(e => e.id === selectedId) || null,
[allEntries, selectedId]
);
// Escape key to close (only when form is not open)
useEffect(() => {
if (!isOpen || showForm) return;
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, showForm, onClose]);
// Prevent body scroll
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}
}, [isOpen]);
const handleRowClick = useCallback((entry) => {
setSelectedId(entry.id);
}, []);
const handleRowDoubleClick = useCallback((entry) => {
if (mode === 'select' && onSelect) {
onSelect(entry.id);
onClose();
}
}, [mode, onSelect, onClose]);
const handleAddClick = () => {
// Track current items to detect new one after save
itemsBeforeFormRef.current = new Set(allEntries.map(e => e.id));
setFormMode('add');
setEditData(null);
setShowForm(true);
setError('');
};
const handleEditClick = () => {
if (!selectedEntry) return;
setFormMode('edit');
setEditData(selectedEntry.data);
setShowForm(true);
setError('');
};
const handleFormSave = async (formData) => {
setIsSaving(true);
setError('');
try {
if (formMode === 'add') {
await createEntry(nomenclature.id, formData);
} else {
await updateEntry(selectedId, { data: formData });
}
setShowForm(false);
} catch (err) {
const data = err.response?.data;
if (typeof data === 'string') {
setError(data);
} else if (data) {
const messages = [];
for (const [key, val] of Object.entries(data)) {
if (Array.isArray(val)) messages.push(`${key}: ${val.join(', ')}`);
else if (typeof val === 'string') messages.push(`${key}: ${val}`);
else messages.push(`${key}: ${JSON.stringify(val)}`);
}
setError(messages.join('; ') || 'Failed to save');
} else {
setError('Failed to save entry');
}
} finally {
setIsSaving(false);
}
};
const handleDeleteClick = async () => {
if (!selectedEntry) return;
if (!window.confirm('Delete this entry?')) return;
setError('');
try {
await deleteEntry(selectedId);
setSelectedId(null);
} catch {
setError('Failed to delete entry');
}
};
const handleToggleActive = async () => {
if (!selectedEntry) return;
setError('');
try {
await toggleEntryActive(selectedId, selectedEntry.is_active);
} catch {
setError('Failed to update entry');
}
};
const handleSelectClick = () => {
if (!selectedEntry || !onSelect) return;
onSelect(selectedEntry.id);
onClose();
};
// Smart backdrop click: only close if mousedown+mouseup both on backdrop
const mouseDownTargetRef = useRef(null);
const handleMouseDown = (e) => { mouseDownTargetRef.current = e.target; };
const handleMouseUp = (e) => {
if (mouseDownTargetRef.current === overlayRef.current && e.target === overlayRef.current) {
onClose();
}
mouseDownTargetRef.current = null;
};
if (!isOpen || !nomenclature) return null;
return createPortal(
<div
className="nui-mgmt-overlay"
ref={overlayRef}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<div className="nui-mgmt-content" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="nui-mgmt-header">
<h2>{nomenclature.name}</h2>
<button className="nui-mgmt-close" onClick={onClose} type="button">&times;</button>
</div>
{/* Toolbar */}
<div className="nui-mgmt-toolbar">
<input
ref={searchInputRef}
type="text"
className="nui-mgmt-search"
placeholder={`Search ${nomenclature.name}...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<button className="nui-mgmt-add-btn" onClick={handleAddClick} type="button">
+ Add New
</button>
</div>
{error && <div className="nui-mgmt-error">{error}</div>}
{/* Table */}
<div className="nui-mgmt-table-container">
<table className="nui-mgmt-table">
<thead>
<tr>
<th className="nui-mgmt-th-id">ID</th>
{sortedFields.map(f => (
<th key={f.key}>{f.label || f.key}</th>
))}
<th className="nui-mgmt-th-status">Active</th>
<th className="nui-mgmt-th-actions"></th>
</tr>
</thead>
<tbody>
{filteredEntries.length === 0 ? (
<tr>
<td colSpan={sortedFields.length + 3} className="nui-mgmt-empty">
{searchTerm ? 'No entries match your search' : 'No entries yet. Click "+ Add New" to create one.'}
</td>
</tr>
) : (
filteredEntries.map(entry => (
<tr
key={entry.id}
ref={entry.id === selectedId ? selectedRowRef : undefined}
className={`nui-mgmt-row ${selectedId === entry.id ? 'nui-mgmt-row--selected' : ''} ${!entry.is_active ? 'nui-mgmt-row--inactive' : ''}`}
onClick={() => handleRowClick(entry)}
onDoubleClick={() => handleRowDoubleClick(entry)}
>
<td className="nui-mgmt-td-id">{entry.id}</td>
{sortedFields.map(f => (
<td key={f.key}>
{f.field_type === 'bool'
? (entry.data?.[f.key] ? 'Yes' : 'No')
: (entry.data?.[f.key] ?? '')
}
</td>
))}
<td className="nui-mgmt-td-status">
<span
className={`nui-mgmt-active-badge ${entry.is_active ? 'nui-mgmt-active-badge--active' : 'nui-mgmt-active-badge--inactive'}`}
onClick={(e) => { e.stopPropagation(); setSelectedId(entry.id); toggleEntryActive(entry.id, entry.is_active); }}
title={entry.is_active ? 'Click to deactivate' : 'Click to activate'}
>
{entry.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="nui-mgmt-td-actions">
<button
className="nui-mgmt-edit-btn"
onClick={(e) => { e.stopPropagation(); setSelectedId(entry.id); setTimeout(() => { setFormMode('edit'); setEditData(entry.data); setShowForm(true); }, 0); }}
title="Edit"
type="button"
>
&#9998;
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Footer */}
<div className="nui-mgmt-footer">
<div className="nui-mgmt-footer-info">
{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}
{searchTerm && ` (filtered)`}
</div>
<div className="nui-mgmt-footer-actions">
{selectedEntry && (
<>
<button
className="nui-mgmt-delete-btn"
onClick={handleDeleteClick}
type="button"
>
Delete
</button>
<button
className="nui-mgmt-footer-edit-btn"
onClick={handleEditClick}
type="button"
>
Edit
</button>
</>
)}
{mode === 'select' && (
<button
className="nui-mgmt-select-btn"
onClick={handleSelectClick}
disabled={!selectedEntry}
type="button"
>
Select
</button>
)}
</div>
</div>
</div>
{/* Form overlay (renders on top, z-index 13000) */}
<FormOverlay
isOpen={showForm}
onClose={() => setShowForm(false)}
title={formMode === 'edit' ? `Edit ${nomenclature.name} Entry` : `Add New ${nomenclature.name} Entry`}
>
<NomenclatureEntryForm
fields={sortedFields}
mode={formMode}
initialData={editData}
onSave={handleFormSave}
onCancel={() => setShowForm(false)}
isSaving={isSaving}
/>
</FormOverlay>
</div>,
document.body
);
}
@@ -10,3 +10,146 @@
flex: 1;
overflow: hidden;
}
/* Load Report Dialog */
.load-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.load-dialog {
background: white;
border-radius: 8px;
width: 500px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.load-dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.load-dialog-header h2 {
margin: 0;
font-size: 18px;
color: #333;
}
.load-dialog-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
padding: 0;
line-height: 1;
}
.load-dialog-close:hover {
color: #333;
}
.load-dialog-actions {
padding: 12px 20px;
border-bottom: 1px solid #eee;
}
.load-dialog-new-btn {
padding: 8px 16px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.load-dialog-new-btn:hover {
background: #5a6fd6;
}
.load-dialog-content {
flex: 1;
overflow-y: auto;
padding: 12px 0;
}
.load-dialog-loading,
.load-dialog-empty {
padding: 40px 20px;
text-align: center;
color: #999;
}
.load-dialog-list {
list-style: none;
margin: 0;
padding: 0;
}
.load-dialog-item {
display: flex;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
}
.load-dialog-item:hover {
background: #f8f8f8;
}
.load-dialog-item--current {
background: #e8f0ff;
}
.load-dialog-item--current:hover {
background: #dde8ff;
}
.load-dialog-item-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.load-dialog-item-name {
font-weight: 500;
color: #333;
}
.load-dialog-item-date {
font-size: 12px;
color: #999;
}
.load-dialog-item-delete {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
opacity: 0.5;
transition: opacity 0.2s;
}
.load-dialog-item-delete:hover {
opacity: 1;
}
@@ -6,6 +6,7 @@ import EditorCanvas from './EditorCanvas';
import ConfigPanel from './ConfigPanel';
import CharacterPalette from './CharacterPalette';
import ObjectInspector from './ObjectInspector';
import api from '../../services/api';
import './ReportEditor.css';
function ReportEditorContent() {
@@ -25,6 +26,13 @@ function ReportEditorContent() {
const [selectedChar, setSelectedChar] = useState('─');
const [showCharacterPalette, setShowCharacterPalette] = useState(false);
// Save/Load state
const [reportId, setReportId] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [showLoadDialog, setShowLoadDialog] = useState(false);
const [savedReports, setSavedReports] = useState([]);
const [loadingReports, setLoadingReports] = useState(false);
const handleAddElement = (element) => {
setReport(prev => {
// Check if the new element is positioned inside a band
@@ -178,6 +186,112 @@ function ReportEditorContent() {
}));
};
const handleReportNameChange = (name) => {
setReport(prev => ({ ...prev, name }));
};
// Save report to backend
const handleSave = async () => {
if (!report.name.trim()) {
alert('Please enter a report name');
return;
}
setIsSaving(true);
try {
const payload = {
name: report.name,
page_width: report.pageWidth,
page_height: report.pageHeight,
api_endpoint: report.apiEndpoint,
elements: report.elements
};
if (reportId) {
// Update existing report
await api.put(`/api/reports/${reportId}/`, payload);
} else {
// Create new report
const res = await api.post('/api/reports/', payload);
setReportId(res.data.id);
}
alert('Report saved successfully');
} catch (err) {
console.error('Failed to save report:', err);
alert('Failed to save report: ' + (err.response?.data?.detail || err.message));
} finally {
setIsSaving(false);
}
};
// Load reports list for dialog
const handleLoadClick = async () => {
setLoadingReports(true);
setShowLoadDialog(true);
try {
const res = await api.get('/api/reports/');
setSavedReports(res.data.results || res.data || []);
} catch (err) {
console.error('Failed to load reports list:', err);
alert('Failed to load reports list');
} finally {
setLoadingReports(false);
}
};
// Load a specific report
const handleLoadReport = async (id) => {
try {
const res = await api.get(`/api/reports/${id}/`);
const data = res.data;
setReport({
name: data.name,
pageWidth: data.page_width,
pageHeight: data.page_height,
apiEndpoint: data.api_endpoint || '',
elements: data.elements || []
});
setReportId(data.id);
setSelectedElementIds([]);
setShowLoadDialog(false);
} catch (err) {
console.error('Failed to load report:', err);
alert('Failed to load report');
}
};
// Delete a report
const handleDeleteReport = async (id) => {
if (!window.confirm('Are you sure you want to delete this report?')) return;
try {
await api.delete(`/api/reports/${id}/`);
setSavedReports(prev => prev.filter(r => r.id !== id));
if (reportId === id) {
setReportId(null);
}
} catch (err) {
console.error('Failed to delete report:', err);
alert('Failed to delete report');
}
};
// New report
const handleNewReport = () => {
if (report.elements.length > 0) {
if (!window.confirm('Discard current report and create a new one?')) return;
}
setReport({
name: 'Untitled Report',
pageWidth: 80,
pageHeight: 66,
apiEndpoint: '',
elements: []
});
setReportId(null);
setSelectedElementIds([]);
setShowLoadDialog(false);
};
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
@@ -217,6 +331,11 @@ function ReportEditorContent() {
previewMode={previewMode}
onTogglePreview={handleTogglePreview}
onConfigureAPI={() => setShowConfigPanel(true)}
onSave={handleSave}
onLoad={handleLoadClick}
reportName={report.name}
onReportNameChange={handleReportNameChange}
isSaving={isSaving}
/>
<div className="editor-layout">
<EditorCanvas
@@ -257,6 +376,53 @@ function ReportEditorContent() {
}}
/>
)}
{/* Load Report Dialog */}
{showLoadDialog && (
<div className="load-dialog-overlay" onClick={() => setShowLoadDialog(false)}>
<div className="load-dialog" onClick={e => e.stopPropagation()}>
<div className="load-dialog-header">
<h2>Load Report</h2>
<button className="load-dialog-close" onClick={() => setShowLoadDialog(false)}>×</button>
</div>
<div className="load-dialog-actions">
<button className="load-dialog-new-btn" onClick={handleNewReport}>
+ New Report
</button>
</div>
<div className="load-dialog-content">
{loadingReports ? (
<div className="load-dialog-loading">Loading...</div>
) : savedReports.length === 0 ? (
<div className="load-dialog-empty">No saved reports</div>
) : (
<ul className="load-dialog-list">
{savedReports.map(r => (
<li
key={r.id}
className={`load-dialog-item ${reportId === r.id ? 'load-dialog-item--current' : ''}`}
>
<div className="load-dialog-item-info" onClick={() => handleLoadReport(r.id)}>
<span className="load-dialog-item-name">{r.name}</span>
<span className="load-dialog-item-date">
{new Date(r.updated_at).toLocaleDateString()}
</span>
</div>
<button
className="load-dialog-item-delete"
onClick={() => handleDeleteReport(r.id)}
title="Delete report"
>
🗑
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -50,3 +50,33 @@
border-color: rgba(255, 255, 255, 0.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.toolbar-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.toolbar-group--file {
margin-left: auto;
}
.toolbar-report-name {
padding: 8px 12px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
font-size: 14px;
width: 200px;
color: #333;
}
.toolbar-report-name::placeholder {
color: #999;
}
.toolbar-report-name:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
}
@@ -8,7 +8,12 @@ function Toolbar({
onBorderStyleChange,
previewMode = false,
onTogglePreview,
onConfigureAPI
onConfigureAPI,
onSave,
onLoad,
reportName,
onReportNameChange,
isSaving = false
}) {
return (
<div className="toolbar">
@@ -107,6 +112,32 @@ function Toolbar({
</button>
)}
</div>
<div className="toolbar-separator"></div>
<div className="toolbar-group toolbar-group--file">
<input
type="text"
className="toolbar-report-name"
value={reportName || ''}
onChange={e => onReportNameChange && onReportNameChange(e.target.value)}
placeholder="Report name"
title="Report name"
/>
<button
className="toolbar-button"
onClick={onSave}
disabled={isSaving}
title="Save Report"
>
{isSaving ? '...' : '💾 Save'}
</button>
<button
className="toolbar-button"
onClick={onLoad}
title="Load Report"
>
📂 Load
</button>
</div>
</div>
);
}