added vehicles and vehicles extra, dynamic tables for entities, dynamic dropdowns with overlay for selecting and manage data
parent
ed35a90cc0
commit
6a42099169
@ -0,0 +1,52 @@
|
||||
# Generated by Django 4.2.8 on 2026-02-05 23:24
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("api", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Report",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("page_width", models.IntegerField(default=80)),
|
||||
("page_height", models.IntegerField(default=66)),
|
||||
(
|
||||
"api_endpoint",
|
||||
models.CharField(blank=True, default="", max_length=500),
|
||||
),
|
||||
("elements", models.JSONField(default=list)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="reports",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-updated_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,3 +1,21 @@
|
||||
from django.contrib import admin
|
||||
from .models import Nomenclature, NomenclatureField, NomenclatureEntry
|
||||
|
||||
# Register your models here.
|
||||
|
||||
class NomenclatureFieldInline(admin.TabularInline):
|
||||
model = NomenclatureField
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(Nomenclature)
|
||||
class NomenclatureAdmin(admin.ModelAdmin):
|
||||
list_display = ['code', 'name', 'applies_to', 'kind', 'sort_order']
|
||||
list_filter = ['applies_to', 'kind']
|
||||
search_fields = ['code', 'name']
|
||||
inlines = [NomenclatureFieldInline]
|
||||
|
||||
|
||||
@admin.register(NomenclatureEntry)
|
||||
class NomenclatureEntryAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', '__str__', 'nomenclature', 'is_active']
|
||||
list_filter = ['nomenclature', 'is_active']
|
||||
|
||||
@ -0,0 +1,111 @@
|
||||
# Generated by Django 4.2.8 on 2026-02-05 15:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("nomenclatures", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="nomenclature",
|
||||
options={"ordering": ["sort_order", "code"]},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="nomenclaturefield",
|
||||
options={"ordering": ["sort_order", "key"]},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclature",
|
||||
name="display_field",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="name",
|
||||
help_text="Key of the NomenclatureField to use as dropdown label (for lookup kind)",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclature",
|
||||
name="kind",
|
||||
field=models.CharField(
|
||||
choices=[("lookup", "Lookup Table"), ("field", "Custom Field")],
|
||||
default="lookup",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclature",
|
||||
name="sort_order",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclaturefield",
|
||||
name="choices",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
help_text='List of allowed values for choice type, e.g. ["Red","Green","Blue"]',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclaturefield",
|
||||
name="label",
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclaturefield",
|
||||
name="required",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclaturefield",
|
||||
name="sort_order",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="nomenclaturefield",
|
||||
name="nomenclature",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="fields",
|
||||
to="nomenclatures.nomenclature",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="nomenclaturefield",
|
||||
unique_together={("nomenclature", "key")},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="NomenclatureEntry",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("data", models.JSONField(default=dict)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
(
|
||||
"nomenclature",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="entries",
|
||||
to="nomenclatures.nomenclature",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name_plural": "Nomenclature entries",
|
||||
"ordering": ["id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,58 @@
|
||||
# Generated by Django 4.2.8 on 2026-01-29 13:08
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("vehicles", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="gross",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="gross_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="gross_user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="user_vehicle_gross",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="tare",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="tare_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="tare_user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="user_vehicle_tare",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,31 @@
|
||||
# Generated by Django 4.2.8 on 2026-02-10 15:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"vehicles",
|
||||
"0002_vehicle_gross_vehicle_gross_date_vehicle_gross_user_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="driver_pid",
|
||||
field=models.CharField(blank=True, max_length=50, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="trailer1_number",
|
||||
field=models.CharField(blank=True, max_length=15, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="trailer2_number",
|
||||
field=models.CharField(blank=True, max_length=15, null=True),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useNomenclatures } from './NomenclatureContext';
|
||||
|
||||
const NomenclatureDataContext = createContext();
|
||||
|
||||
export const useNomenclatureData = () => {
|
||||
const context = useContext(NomenclatureDataContext);
|
||||
if (!context) {
|
||||
throw new Error('useNomenclatureData must be used within a NomenclatureDataProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const NomenclatureDataProvider = ({ children }) => {
|
||||
const { subscribeToUpdates } = useNomenclatures();
|
||||
|
||||
// definitions: { CODE: { id, code, name, kind, display_field, applies_to, sort_order, fields: [...] } }
|
||||
const [definitions, setDefinitions] = useState({});
|
||||
// entries: { CODE: [ { id, data: {...}, is_active, display_value } ] }
|
||||
const [entries, setEntries] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Ref to track definitions for SSE handler (avoids stale closure)
|
||||
const definitionsRef = useRef({});
|
||||
useEffect(() => { definitionsRef.current = definitions; }, [definitions]);
|
||||
|
||||
// Load all nomenclature definitions and their entries
|
||||
const loadAll = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get('/api/nomenclatures/');
|
||||
const data = res.data.results || res.data;
|
||||
const noms = Array.isArray(data) ? data : [];
|
||||
|
||||
// Build definitions map keyed by code
|
||||
const defsMap = {};
|
||||
for (const nom of noms) {
|
||||
defsMap[nom.code] = nom;
|
||||
}
|
||||
setDefinitions(defsMap);
|
||||
definitionsRef.current = defsMap;
|
||||
|
||||
// Load entries for all lookup nomenclatures in parallel
|
||||
const lookupNoms = noms.filter(n => n.kind === 'lookup');
|
||||
const entryResults = await Promise.allSettled(
|
||||
lookupNoms.map(nom =>
|
||||
api.get(`/api/nomenclatures/${nom.id}/entries/`).then(res => ({
|
||||
code: nom.code,
|
||||
entries: Array.isArray(res.data) ? res.data : [],
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
const entriesMap = {};
|
||||
for (const result of entryResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
entriesMap[result.value.code] = result.value.entries;
|
||||
}
|
||||
}
|
||||
setEntries(entriesMap);
|
||||
setIsLoaded(true);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load nomenclature data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load on mount
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
// Subscribe to SSE updates for nomenclature and nomenclature_entry events
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToUpdates((message) => {
|
||||
// Nomenclature definition changed - reload everything
|
||||
if (message.object === 'nomenclature') {
|
||||
loadAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.object !== 'nomenclature_entry') return;
|
||||
|
||||
const { operation, data } = message;
|
||||
const nomCode = data?.nomenclature_code;
|
||||
if (!nomCode) return;
|
||||
|
||||
setEntries(prev => {
|
||||
const currentEntries = prev[nomCode] || [];
|
||||
|
||||
if (operation === 'insert') {
|
||||
const exists = currentEntries.some(e => e.id === data.id);
|
||||
if (exists) return prev;
|
||||
return { ...prev, [nomCode]: [...currentEntries, data] };
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const updated = currentEntries.map(e => e.id === data.id ? { ...e, ...data } : e);
|
||||
return { ...prev, [nomCode]: updated };
|
||||
}
|
||||
|
||||
if (operation === 'delete') {
|
||||
return { ...prev, [nomCode]: currentEntries.filter(e => e.id !== data.id) };
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [subscribeToUpdates, loadAll]);
|
||||
|
||||
// Helper: get active entries for a nomenclature code
|
||||
const getActiveEntries = useCallback((code) => {
|
||||
return (entries[code] || []).filter(e => e.is_active);
|
||||
}, [entries]);
|
||||
|
||||
// Helper: get display value for a specific entry
|
||||
const getDisplayValue = useCallback((code, entryId) => {
|
||||
const entry = (entries[code] || []).find(e => e.id === entryId);
|
||||
return entry?.display_value || '';
|
||||
}, [entries]);
|
||||
|
||||
// CRUD: create entry
|
||||
const createEntry = useCallback(async (nomenclatureId, data) => {
|
||||
const res = await api.post(`/api/nomenclatures/${nomenclatureId}/entries/`, { data });
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
// CRUD: update entry
|
||||
const updateEntry = useCallback(async (entryId, payload) => {
|
||||
const res = await api.patch(`/api/nomenclature-entries/${entryId}/`, payload);
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
// CRUD: delete entry
|
||||
const deleteEntry = useCallback(async (entryId) => {
|
||||
await api.delete(`/api/nomenclature-entries/${entryId}/`);
|
||||
}, []);
|
||||
|
||||
// CRUD: toggle is_active
|
||||
const toggleEntryActive = useCallback(async (entryId, currentState) => {
|
||||
const res = await api.patch(`/api/nomenclature-entries/${entryId}/`, {
|
||||
is_active: !currentState,
|
||||
});
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
definitions,
|
||||
entries,
|
||||
isLoading,
|
||||
isLoaded,
|
||||
error,
|
||||
getActiveEntries,
|
||||
getDisplayValue,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
toggleEntryActive,
|
||||
refreshAll: loadAll,
|
||||
};
|
||||
|
||||
return (
|
||||
<NomenclatureDataContext.Provider value={value}>
|
||||
{children}
|
||||
</NomenclatureDataContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
python .\serial_bridge\app.py
|
||||
@ -0,0 +1 @@
|
||||
python .\test_comport_writer\test_writer.py
|
||||
Loading…
Reference in New Issue