added users management and permissions
This commit is contained in:
Binary file not shown.
+33
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-22 19:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("api", "0003_documentcounter"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="can_edit_documents",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="can_manage_entities",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="can_manually_measure",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="can_measure",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -12,6 +12,10 @@ class User(AbstractUser):
|
||||
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer')
|
||||
is_admin = models.BooleanField(default=False)
|
||||
can_measure = models.BooleanField(default=False)
|
||||
can_manually_measure = models.BooleanField(default=False)
|
||||
can_manage_entities = models.BooleanField(default=False)
|
||||
can_edit_documents = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
db_table = 'api_user'
|
||||
|
||||
@@ -11,7 +11,8 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'first_name', 'last_name',
|
||||
'role', 'is_admin', 'is_active', 'date_joined', 'password']
|
||||
'role', 'is_admin', 'is_active', 'date_joined', 'password',
|
||||
'can_measure', 'can_manually_measure', 'can_manage_entities', 'can_edit_documents']
|
||||
read_only_fields = ['id', 'date_joined']
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': True}
|
||||
@@ -40,7 +41,8 @@ class UserDetailSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'first_name', 'last_name',
|
||||
'role', 'is_admin', 'is_active', 'date_joined']
|
||||
'role', 'is_admin', 'is_active', 'date_joined',
|
||||
'can_measure', 'can_manually_measure', 'can_manage_entities', 'can_edit_documents']
|
||||
read_only_fields = ['id', 'date_joined']
|
||||
|
||||
|
||||
|
||||
+19
-3
@@ -14,6 +14,11 @@ from nomenclatures.models import Nomenclature, NomenclatureEntry
|
||||
from scalesapp.sse import sse_broadcast_update
|
||||
|
||||
|
||||
class IsAdminUser(IsAuthenticated):
|
||||
def has_permission(self, request, view):
|
||||
return bool(request.user and request.user.is_authenticated and request.user.is_admin)
|
||||
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for user management.
|
||||
@@ -22,7 +27,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
create: Create a new user
|
||||
retrieve: Get a specific user
|
||||
update: Update a user
|
||||
destroy: Delete a user
|
||||
destroy: Soft-delete a user (sets is_active=False)
|
||||
me: Get current authenticated user
|
||||
change_password: Change password for current user
|
||||
"""
|
||||
@@ -31,13 +36,24 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
filterset_fields = ['role', 'is_admin', 'is_active']
|
||||
ordering = ['username']
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
||||
def get_permissions(self):
|
||||
if self.action in ('me', 'change_password'):
|
||||
return [IsAuthenticated()]
|
||||
return [IsAuthenticated(), IsAdminUser()]
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
instance.is_active = False
|
||||
instance.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def me(self, request):
|
||||
"""Get current authenticated user details"""
|
||||
serializer = UserDetailSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated],
|
||||
@action(detail=False, methods=['post'],
|
||||
url_path='change-password', url_name='change_password')
|
||||
def change_password(self, request):
|
||||
"""Change password for current user"""
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import Login from './components/Users/Login';
|
||||
import Main from './components/Main';
|
||||
import ReportEditor from './components/ReportEditor/ReportEditor';
|
||||
import UserManager from './components/Users/UserManager';
|
||||
import './App.css';
|
||||
import { NomenclatureProvider } from './contexts/NomenclatureContext';
|
||||
import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
|
||||
@@ -40,7 +41,7 @@ import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
|
||||
// }
|
||||
|
||||
function AppContent() {
|
||||
const { user, login, logout, loading, isAuthenticated } = useAuth();
|
||||
const { user, currentUser, login, logout, loading, isAuthenticated } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -59,7 +60,8 @@ function AppContent() {
|
||||
<NomenclatureDataProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Main />} />
|
||||
<Route path="/report-editor" element={<ReportEditor />} />
|
||||
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} />
|
||||
<Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} />
|
||||
</Routes>
|
||||
</NomenclatureDataProvider>
|
||||
</NomenclatureProvider>
|
||||
|
||||
@@ -27,20 +27,33 @@ function Header() {
|
||||
<header className="app-header">
|
||||
<div className="header-content">
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => navigate('/report-editor')}
|
||||
title="Report Editor"
|
||||
>
|
||||
📝
|
||||
</button>
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => setShowNomenclatureManager(true)}
|
||||
title="Nomenclatures"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
{currentUser?.can_edit_documents && (
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => navigate('/report-editor')}
|
||||
title="Report Editor"
|
||||
>
|
||||
📝
|
||||
</button>
|
||||
)}
|
||||
{currentUser?.can_manage_entities && (
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => setShowNomenclatureManager(true)}
|
||||
title="Nomenclatures"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
)}
|
||||
{currentUser?.is_admin && (
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => navigate('/users')}
|
||||
title="User Manager"
|
||||
>
|
||||
👥
|
||||
</button>
|
||||
)}
|
||||
<h1>ScalesApp - Real-time Data Monitor</h1>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../../services/api';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import './NomenclatureManager.css';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
@@ -13,6 +14,8 @@ const EMPTY_FORM = {
|
||||
};
|
||||
|
||||
function NomenclatureManager({ onClose }) {
|
||||
const { currentUser } = useAuth();
|
||||
const canEdit = !!currentUser?.can_manage_entities;
|
||||
const [nomenclatures, setNomenclatures] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [form, setForm] = useState(null); // null = nothing selected
|
||||
@@ -179,7 +182,7 @@ function NomenclatureManager({ onClose }) {
|
||||
<div className="nm-body">
|
||||
{/* Left panel - List */}
|
||||
<div className="nm-list-panel">
|
||||
<button className="nm-add-btn" onClick={handleAdd}>+ Add New</button>
|
||||
{canEdit && <button className="nm-add-btn" onClick={handleAdd}>+ Add New</button>}
|
||||
|
||||
{isLoading && <div className="nm-loading">Loading...</div>}
|
||||
|
||||
@@ -196,13 +199,15 @@ function NomenclatureManager({ onClose }) {
|
||||
{nom.kind === 'lookup' ? 'Lookup' : 'Field'} / {nom.applies_to}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="nm-edit-btn"
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(nom); }}
|
||||
title="Edit"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
className="nm-edit-btn"
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(nom); }}
|
||||
title="Edit"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -216,14 +221,14 @@ function NomenclatureManager({ onClose }) {
|
||||
<div className="nm-form-panel">
|
||||
{!form ? (
|
||||
<div className="nm-placeholder">
|
||||
Select a nomenclature to edit or click "+ Add New"
|
||||
{canEdit ? 'Select a nomenclature to edit or click "+ Add New"' : 'Select a nomenclature to view'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="nm-form">
|
||||
{error && <div className="nm-error">{error}</div>}
|
||||
|
||||
<div className="nm-form-title">
|
||||
{selectedId ? 'Edit Nomenclature' : 'New Nomenclature'}
|
||||
{!canEdit ? 'View Nomenclature' : selectedId ? 'Edit Nomenclature' : 'New Nomenclature'}
|
||||
</div>
|
||||
|
||||
{/* Basic fields */}
|
||||
@@ -235,7 +240,7 @@ function NomenclatureManager({ onClose }) {
|
||||
value={form.code}
|
||||
onChange={e => setForm(prev => ({ ...prev, code: e.target.value }))}
|
||||
placeholder="e.g. cargo_type"
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || !canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -246,7 +251,7 @@ function NomenclatureManager({ onClose }) {
|
||||
value={form.name}
|
||||
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="e.g. Cargo Type"
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || !canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +260,7 @@ function NomenclatureManager({ onClose }) {
|
||||
<select
|
||||
value={form.applies_to}
|
||||
onChange={e => setForm(prev => ({ ...prev, applies_to: e.target.value }))}
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || !canEdit}
|
||||
>
|
||||
<option value="vehicle">Vehicle</option>
|
||||
<option value="container">Container</option>
|
||||
@@ -267,7 +272,7 @@ function NomenclatureManager({ onClose }) {
|
||||
<select
|
||||
value={form.kind}
|
||||
onChange={e => setForm(prev => ({ ...prev, kind: e.target.value }))}
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || !canEdit}
|
||||
>
|
||||
<option value="lookup">Lookup Table</option>
|
||||
<option value="field">Custom Field</option>
|
||||
@@ -280,7 +285,7 @@ function NomenclatureManager({ onClose }) {
|
||||
<select
|
||||
value={form.display_field}
|
||||
onChange={e => setForm(prev => ({ ...prev, display_field: e.target.value }))}
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || !canEdit}
|
||||
>
|
||||
{form.fields.filter(f => f.key).map(f => (
|
||||
<option key={f.key} value={f.key}>{f.key}</option>
|
||||
@@ -294,9 +299,11 @@ function NomenclatureManager({ onClose }) {
|
||||
<div className="nm-section">
|
||||
<div className="nm-section-header">
|
||||
<h3>Fields</h3>
|
||||
<button className="nm-section-add" onClick={addField} disabled={isSaving}>
|
||||
+ Add Field
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="nm-section-add" onClick={addField} disabled={isSaving || !canEdit}>
|
||||
+ Add Field
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="nm-fields-table">
|
||||
@@ -314,19 +321,19 @@ function NomenclatureManager({ onClose }) {
|
||||
value={field.key}
|
||||
onChange={e => updateField(idx, 'key', e.target.value)}
|
||||
placeholder="key"
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || !canEdit}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={field.label}
|
||||
onChange={e => updateField(idx, 'label', e.target.value)}
|
||||
placeholder="Label"
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || !canEdit}
|
||||
/>
|
||||
<select
|
||||
value={field.field_type}
|
||||
onChange={e => updateField(idx, 'field_type', e.target.value)}
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || !canEdit}
|
||||
>
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
@@ -337,7 +344,7 @@ function NomenclatureManager({ onClose }) {
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={e => updateField(idx, 'required', e.target.checked)}
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || !canEdit}
|
||||
/>
|
||||
<button
|
||||
className="nm-remove-btn"
|
||||
@@ -354,7 +361,7 @@ function NomenclatureManager({ onClose }) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="nm-form-actions">
|
||||
{selectedId && (
|
||||
{canEdit && selectedId && (
|
||||
<button
|
||||
className="nm-delete-btn"
|
||||
onClick={handleDelete}
|
||||
@@ -369,15 +376,17 @@ function NomenclatureManager({ onClose }) {
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="nm-save-btn"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
{canEdit ? 'Cancel' : 'Close'}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
className="nm-save-btn"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNomenclatureData } from '../../contexts/NomenclatureDataContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import FormOverlay from './FormOverlay';
|
||||
import NomenclatureEntryForm from './NomenclatureEntryForm';
|
||||
import './NomenclatureManagementOverlay.css';
|
||||
@@ -13,6 +14,9 @@ export default function NomenclatureManagementOverlay({
|
||||
initialSelection = null, // entry ID to pre-select
|
||||
onSelect, // called in 'select' mode when user picks entry
|
||||
}) {
|
||||
const { currentUser } = useAuth();
|
||||
const canEdit = !!currentUser?.can_manage_entities;
|
||||
|
||||
const {
|
||||
definitions, entries,
|
||||
createEntry, updateEntry, deleteEntry, toggleEntryActive,
|
||||
@@ -238,9 +242,11 @@ export default function NomenclatureManagementOverlay({
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<button className="nui-mgmt-add-btn" onClick={handleAddClick} type="button">
|
||||
+ Add New
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="nui-mgmt-add-btn" onClick={handleAddClick} type="button">
|
||||
+ Add New
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="nui-mgmt-error">{error}</div>}
|
||||
@@ -255,7 +261,7 @@ export default function NomenclatureManagementOverlay({
|
||||
<th key={f.key}>{f.label || f.key}</th>
|
||||
))}
|
||||
<th className="nui-mgmt-th-status">Active</th>
|
||||
<th className="nui-mgmt-th-actions"></th>
|
||||
{canEdit && <th className="nui-mgmt-th-actions"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -286,22 +292,25 @@ export default function NomenclatureManagementOverlay({
|
||||
<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'}
|
||||
onClick={canEdit ? (e) => { e.stopPropagation(); setSelectedId(entry.id); toggleEntryActive(entry.id, entry.is_active); } : undefined}
|
||||
title={canEdit ? (entry.is_active ? 'Click to deactivate' : 'Click to activate') : undefined}
|
||||
style={canEdit ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{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>
|
||||
{canEdit && (
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
@@ -316,7 +325,7 @@ export default function NomenclatureManagementOverlay({
|
||||
{searchTerm && ` (filtered)`}
|
||||
</div>
|
||||
<div className="nui-mgmt-footer-actions">
|
||||
{selectedEntry && (
|
||||
{canEdit && selectedEntry && (
|
||||
<>
|
||||
<button
|
||||
className="nui-mgmt-delete-btn"
|
||||
|
||||
@@ -4,9 +4,9 @@ import { DataProvider, useReportData } from './DataContext';
|
||||
import { BandProvider } from './BandContext';
|
||||
import Toolbar from './Toolbar';
|
||||
import EditorCanvas from './EditorCanvas';
|
||||
import ConfigPanel from './ConfigPanel';
|
||||
import CharacterPalette from './CharacterPalette';
|
||||
import ObjectInspector from './ObjectInspector';
|
||||
import { useNomenclatureData } from '../../contexts/NomenclatureDataContext';
|
||||
import api from '../../services/api';
|
||||
import './ReportEditor.css';
|
||||
|
||||
@@ -23,7 +23,6 @@ function ReportEditorContent() {
|
||||
const [toolMode, setToolMode] = useState('select');
|
||||
const [borderStyle, setBorderStyle] = useState('single');
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [showConfigPanel, setShowConfigPanel] = useState(false);
|
||||
const [selectedChar, setSelectedChar] = useState('─');
|
||||
const [showCharacterPalette, setShowCharacterPalette] = useState(false);
|
||||
|
||||
@@ -37,6 +36,7 @@ function ReportEditorContent() {
|
||||
// Auto-load report and vehicle data passed via router state
|
||||
const location = useLocation();
|
||||
const { setData } = useReportData();
|
||||
const { definitions, entries, isLoaded } = useNomenclatureData();
|
||||
|
||||
useEffect(() => {
|
||||
const { reportName, vehicleData } = location.state || {};
|
||||
@@ -66,6 +66,55 @@ function ReportEditorContent() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// When no vehicle data was passed (direct navigation), auto-set sample data
|
||||
// once nomenclatures have loaded so field bindings can be designed
|
||||
useEffect(() => {
|
||||
if (!isLoaded || location.state?.vehicleData) return;
|
||||
|
||||
const extra = {};
|
||||
for (const def of Object.values(definitions)) {
|
||||
if (def.kind === 'lookup') {
|
||||
const activeEntries = (entries[def.code] || []).filter(e => e.is_active);
|
||||
const firstEntry = activeEntries[0];
|
||||
if (firstEntry?.data && Object.keys(firstEntry.data).length > 0) {
|
||||
extra[def.name] = { ...firstEntry.data };
|
||||
} else if (def.fields?.length > 0) {
|
||||
const sample = {};
|
||||
for (const field of def.fields) {
|
||||
if (field.field_type === 'number') sample[field.key] = 0;
|
||||
else if (field.field_type === 'bool') sample[field.key] = false;
|
||||
else sample[field.key] = 'Sample';
|
||||
}
|
||||
extra[def.name] = sample;
|
||||
} else {
|
||||
extra[def.name] = {};
|
||||
}
|
||||
} else {
|
||||
extra[def.name] = 'Sample Text';
|
||||
}
|
||||
}
|
||||
|
||||
setData({
|
||||
vehicle: {
|
||||
vehicle_number: 'АА1234ВВ',
|
||||
trailer1_number: 'ПВ5678ВВ',
|
||||
trailer2_number: null,
|
||||
driver_pid: '123456789',
|
||||
tare: 8500,
|
||||
tare_date: '2026-02-22T08:00:00Z',
|
||||
tare_user_name: 'operator',
|
||||
gross: 28500,
|
||||
gross_date: '2026-02-22T10:30:00Z',
|
||||
gross_user_name: 'operator',
|
||||
net: 20000,
|
||||
net_date: '2026-02-22T10:30:00Z',
|
||||
net_user_name: 'operator',
|
||||
doc_number: '2026-001',
|
||||
extra,
|
||||
}
|
||||
});
|
||||
}, [isLoaded]);
|
||||
|
||||
const handleAddElement = (element) => {
|
||||
setReport(prev => {
|
||||
// Check if the new element is positioned inside a band
|
||||
@@ -332,9 +381,6 @@ function ReportEditorContent() {
|
||||
setSelectedElementIds([]);
|
||||
setToolMode('select');
|
||||
setShowCharacterPalette(false);
|
||||
if (showConfigPanel) {
|
||||
setShowConfigPanel(false);
|
||||
}
|
||||
} else if (e.key.toLowerCase() === 'd' && !e.ctrlKey && !e.metaKey && e.target.tagName !== 'INPUT') {
|
||||
handleToolChange('drawTable');
|
||||
} else if (e.key === 'Delete' && selectedElementIds.length > 0) {
|
||||
@@ -347,7 +393,7 @@ function ReportEditorContent() {
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showConfigPanel, selectedElementIds, toolMode, report.elements]);
|
||||
}, [selectedElementIds, toolMode, report.elements]);
|
||||
|
||||
// Get the selected element for the Object Inspector
|
||||
const selectedElement = selectedElementIds.length === 1
|
||||
@@ -363,7 +409,6 @@ function ReportEditorContent() {
|
||||
onBorderStyleChange={setBorderStyle}
|
||||
previewMode={previewMode}
|
||||
onTogglePreview={handleTogglePreview}
|
||||
onConfigureAPI={() => setShowConfigPanel(true)}
|
||||
onSave={handleSave}
|
||||
onLoad={handleLoadClick}
|
||||
reportName={report.name}
|
||||
@@ -393,12 +438,6 @@ function ReportEditorContent() {
|
||||
onUpdate={(updates) => handleElementUpdate(selectedElementIds[0], updates)}
|
||||
/>
|
||||
)}
|
||||
<ConfigPanel
|
||||
apiEndpoint={report.apiEndpoint}
|
||||
onApiEndpointChange={handleApiEndpointChange}
|
||||
isOpen={showConfigPanel}
|
||||
onClose={() => setShowConfigPanel(false)}
|
||||
/>
|
||||
{showCharacterPalette && (
|
||||
<CharacterPalette
|
||||
selectedChar={selectedChar}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import api from '../../services/api';
|
||||
import Header from '../Header';
|
||||
|
||||
const PERMISSION_FIELDS = [
|
||||
{ key: 'can_measure', label: 'Can Measure' },
|
||||
{ key: 'can_manually_measure', label: 'Can Manually Measure' },
|
||||
{ key: 'can_manage_entities', label: 'Can Manage Entities' },
|
||||
{ key: 'can_edit_documents', label: 'Can Edit Documents' },
|
||||
];
|
||||
|
||||
const DEFAULT_NEW_USER = {
|
||||
username: '',
|
||||
password: '',
|
||||
can_measure: false,
|
||||
can_manually_measure: false,
|
||||
can_manage_entities: false,
|
||||
can_edit_documents: false,
|
||||
};
|
||||
|
||||
export default function UserManager() {
|
||||
const { currentUser } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [addFormOpen, setAddFormOpen] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ ...DEFAULT_NEW_USER });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/api/users/')
|
||||
.then(res => setUsers(res.data.results ?? res.data))
|
||||
.catch(() => setError('Failed to load users'));
|
||||
}, []);
|
||||
|
||||
const handlePermissionChange = async (userId, field, value) => {
|
||||
setError('');
|
||||
try {
|
||||
const res = await api.patch(`/api/users/${userId}/`, { [field]: value });
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, ...res.data } : u));
|
||||
} catch {
|
||||
setError('Failed to update permission');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (userId) => {
|
||||
setError('');
|
||||
try {
|
||||
await api.delete(`/api/users/${userId}/`);
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, is_active: false } : u));
|
||||
} catch {
|
||||
setError('Failed to deactivate user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivate = async (userId) => {
|
||||
setError('');
|
||||
try {
|
||||
const res = await api.patch(`/api/users/${userId}/`, { is_active: true });
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, ...res.data } : u));
|
||||
} catch {
|
||||
setError('Failed to reactivate user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
const res = await api.post('/api/users/', {
|
||||
username: newUser.username,
|
||||
password: newUser.password,
|
||||
can_measure: newUser.can_measure,
|
||||
can_manually_measure: newUser.can_manually_measure,
|
||||
can_manage_entities: newUser.can_manage_entities,
|
||||
can_edit_documents: newUser.can_edit_documents,
|
||||
});
|
||||
setUsers(prev => [...prev, res.data]);
|
||||
setNewUser({ ...DEFAULT_NEW_USER });
|
||||
setAddFormOpen(false);
|
||||
} catch (err) {
|
||||
const data = err.response?.data;
|
||||
const msg = data?.username?.[0] || data?.detail || (typeof data === 'string' ? data : JSON.stringify(data)) || 'Failed to create user';
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<div style={{ padding: '24px', maxWidth: '900px' }}>
|
||||
<h2 className="vehicle-detail-title">User Manager</h2>
|
||||
|
||||
{error && <div className="vehicle-error">{error}</div>}
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<button
|
||||
className="vehicle-add-btn"
|
||||
onClick={() => setAddFormOpen(prev => !prev)}
|
||||
>
|
||||
{addFormOpen ? 'Cancel' : '+ Add User'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addFormOpen && (
|
||||
<form onSubmit={handleAddUser} style={{ marginBottom: '24px', padding: '16px', border: '1px solid #e0e0e0', borderRadius: '8px', background: '#fafafa' }}>
|
||||
<h3 style={{ margin: '0 0 14px 0', fontSize: '14px', color: '#667eea', fontWeight: 600 }}>New User</h3>
|
||||
<div className="extra-fields">
|
||||
<div className="extra-field">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.username}
|
||||
onChange={e => setNewUser(prev => ({ ...prev, username: e.target.value }))}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="extra-field">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newUser.password}
|
||||
onChange={e => setNewUser(prev => ({ ...prev, password: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{PERMISSION_FIELDS.map(({ key, label }) => (
|
||||
<div key={key} className="extra-field" style={{ flexDirection: 'row', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`new-${key}`}
|
||||
checked={newUser[key]}
|
||||
onChange={e => setNewUser(prev => ({ ...prev, [key]: e.target.checked }))}
|
||||
/>
|
||||
<label htmlFor={`new-${key}`} style={{ textTransform: 'none', letterSpacing: 'normal', cursor: 'pointer' }}>{label}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="vehicle-form-actions" style={{ marginTop: '14px', paddingTop: '12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="vehicle-form-cancel"
|
||||
onClick={() => { setAddFormOpen(false); setNewUser({ ...DEFAULT_NEW_USER }); }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="extra-save-btn"
|
||||
disabled={!newUser.username.trim() || !newUser.password.trim()}
|
||||
>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e0e0e0', textAlign: 'left' }}>
|
||||
<th style={{ padding: '10px 12px', color: '#555', fontWeight: 600 }}>Username</th>
|
||||
<th style={{ padding: '10px 12px', color: '#555', fontWeight: 600 }}>Status</th>
|
||||
{PERMISSION_FIELDS.map(({ key, label }) => (
|
||||
<th key={key} style={{ padding: '10px 12px', color: '#555', fontWeight: 600 }}>{label}</th>
|
||||
))}
|
||||
<th style={{ padding: '10px 12px', color: '#555', fontWeight: 600 }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} style={{ borderBottom: '1px solid #f0f0f0', background: u.is_active ? 'white' : '#fafafa' }}>
|
||||
<td style={{ padding: '10px 12px', fontWeight: 600, color: '#333' }}>{u.username}</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
background: u.is_active ? '#e8f5e9' : '#fce4ec',
|
||||
color: u.is_active ? '#2e7d32' : '#c62828',
|
||||
}}>
|
||||
{u.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
{PERMISSION_FIELDS.map(({ key }) => (
|
||||
<td key={key} style={{ padding: '10px 12px', textAlign: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!u[key]}
|
||||
onChange={e => handlePermissionChange(u.id, key, e.target.checked)}
|
||||
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
{u.is_active ? (
|
||||
<button
|
||||
className="vehicle-form-cancel"
|
||||
onClick={() => handleDeactivate(u.id)}
|
||||
disabled={u.id === currentUser?.id}
|
||||
title={u.id === currentUser?.id ? 'Cannot deactivate yourself' : undefined}
|
||||
style={{ fontSize: '12px', padding: '5px 12px' }}
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="extra-save-btn"
|
||||
onClick={() => handleReactivate(u.id)}
|
||||
style={{ fontSize: '12px', padding: '5px 12px' }}
|
||||
>
|
||||
Reactivate
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={PERMISSION_FIELDS.length + 3} style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user