added vehicles and vehicles extra, dynamic tables for entities, dynamic dropdowns with overlay for selecting and manage data
This commit is contained in:
@@ -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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>×</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"
|
||||
>
|
||||
✎
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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}`}
|
||||
>
|
||||
…
|
||||
</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">×</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"
|
||||
>
|
||||
✎
|
||||
</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;
|
||||
}
|
||||
|
||||
+166
@@ -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);
|
||||
}
|
||||
|
||||
+32
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user