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')
|
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer')
|
||||||
is_admin = models.BooleanField(default=False)
|
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:
|
class Meta:
|
||||||
db_table = 'api_user'
|
db_table = 'api_user'
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ['id', 'username', 'email', 'first_name', 'last_name',
|
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']
|
read_only_fields = ['id', 'date_joined']
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
'password': {'write_only': True}
|
'password': {'write_only': True}
|
||||||
@@ -40,7 +41,8 @@ class UserDetailSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ['id', 'username', 'email', 'first_name', 'last_name',
|
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']
|
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
|
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):
|
class UserViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
API endpoint for user management.
|
API endpoint for user management.
|
||||||
@@ -22,7 +27,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
create: Create a new user
|
create: Create a new user
|
||||||
retrieve: Get a specific user
|
retrieve: Get a specific user
|
||||||
update: Update a user
|
update: Update a user
|
||||||
destroy: Delete a user
|
destroy: Soft-delete a user (sets is_active=False)
|
||||||
me: Get current authenticated user
|
me: Get current authenticated user
|
||||||
change_password: Change password for current user
|
change_password: Change password for current user
|
||||||
"""
|
"""
|
||||||
@@ -31,13 +36,24 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
filterset_fields = ['role', 'is_admin', 'is_active']
|
filterset_fields = ['role', 'is_admin', 'is_active']
|
||||||
ordering = ['username']
|
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):
|
def me(self, request):
|
||||||
"""Get current authenticated user details"""
|
"""Get current authenticated user details"""
|
||||||
serializer = UserDetailSerializer(request.user)
|
serializer = UserDetailSerializer(request.user)
|
||||||
return Response(serializer.data)
|
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')
|
url_path='change-password', url_name='change_password')
|
||||||
def change_password(self, request):
|
def change_password(self, request):
|
||||||
"""Change password for current user"""
|
"""Change password for current user"""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
|
|||||||
import Login from './components/Users/Login';
|
import Login from './components/Users/Login';
|
||||||
import Main from './components/Main';
|
import Main from './components/Main';
|
||||||
import ReportEditor from './components/ReportEditor/ReportEditor';
|
import ReportEditor from './components/ReportEditor/ReportEditor';
|
||||||
|
import UserManager from './components/Users/UserManager';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { NomenclatureProvider } from './contexts/NomenclatureContext';
|
import { NomenclatureProvider } from './contexts/NomenclatureContext';
|
||||||
import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
|
import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
|
||||||
@@ -40,7 +41,7 @@ import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { user, login, logout, loading, isAuthenticated } = useAuth();
|
const { user, currentUser, login, logout, loading, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +60,8 @@ function AppContent() {
|
|||||||
<NomenclatureDataProvider>
|
<NomenclatureDataProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Main />} />
|
<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>
|
</Routes>
|
||||||
</NomenclatureDataProvider>
|
</NomenclatureDataProvider>
|
||||||
</NomenclatureProvider>
|
</NomenclatureProvider>
|
||||||
|
|||||||
@@ -27,20 +27,33 @@ function Header() {
|
|||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="header-content">
|
<div className="header-content">
|
||||||
<div className="header-left">
|
<div className="header-left">
|
||||||
<button
|
{currentUser?.can_edit_documents && (
|
||||||
className="nav-button"
|
<button
|
||||||
onClick={() => navigate('/report-editor')}
|
className="nav-button"
|
||||||
title="Report Editor"
|
onClick={() => navigate('/report-editor')}
|
||||||
>
|
title="Report Editor"
|
||||||
📝
|
>
|
||||||
</button>
|
📝
|
||||||
<button
|
</button>
|
||||||
className="nav-button"
|
)}
|
||||||
onClick={() => setShowNomenclatureManager(true)}
|
{currentUser?.can_manage_entities && (
|
||||||
title="Nomenclatures"
|
<button
|
||||||
>
|
className="nav-button"
|
||||||
📋
|
onClick={() => setShowNomenclatureManager(true)}
|
||||||
</button>
|
title="Nomenclatures"
|
||||||
|
>
|
||||||
|
📋
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{currentUser?.is_admin && (
|
||||||
|
<button
|
||||||
|
className="nav-button"
|
||||||
|
onClick={() => navigate('/users')}
|
||||||
|
title="User Manager"
|
||||||
|
>
|
||||||
|
👥
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<h1>ScalesApp - Real-time Data Monitor</h1>
|
<h1>ScalesApp - Real-time Data Monitor</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import './NomenclatureManager.css';
|
import './NomenclatureManager.css';
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
const EMPTY_FORM = {
|
||||||
@@ -13,6 +14,8 @@ const EMPTY_FORM = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function NomenclatureManager({ onClose }) {
|
function NomenclatureManager({ onClose }) {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const canEdit = !!currentUser?.can_manage_entities;
|
||||||
const [nomenclatures, setNomenclatures] = useState([]);
|
const [nomenclatures, setNomenclatures] = useState([]);
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
const [form, setForm] = useState(null); // null = nothing selected
|
const [form, setForm] = useState(null); // null = nothing selected
|
||||||
@@ -179,7 +182,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
<div className="nm-body">
|
<div className="nm-body">
|
||||||
{/* Left panel - List */}
|
{/* Left panel - List */}
|
||||||
<div className="nm-list-panel">
|
<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>}
|
{isLoading && <div className="nm-loading">Loading...</div>}
|
||||||
|
|
||||||
@@ -196,13 +199,15 @@ function NomenclatureManager({ onClose }) {
|
|||||||
{nom.kind === 'lookup' ? 'Lookup' : 'Field'} / {nom.applies_to}
|
{nom.kind === 'lookup' ? 'Lookup' : 'Field'} / {nom.applies_to}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{canEdit && (
|
||||||
className="nm-edit-btn"
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleSelect(nom); }}
|
className="nm-edit-btn"
|
||||||
title="Edit"
|
onClick={(e) => { e.stopPropagation(); handleSelect(nom); }}
|
||||||
>
|
title="Edit"
|
||||||
✎
|
>
|
||||||
</button>
|
✎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -216,14 +221,14 @@ function NomenclatureManager({ onClose }) {
|
|||||||
<div className="nm-form-panel">
|
<div className="nm-form-panel">
|
||||||
{!form ? (
|
{!form ? (
|
||||||
<div className="nm-placeholder">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="nm-form">
|
<div className="nm-form">
|
||||||
{error && <div className="nm-error">{error}</div>}
|
{error && <div className="nm-error">{error}</div>}
|
||||||
|
|
||||||
<div className="nm-form-title">
|
<div className="nm-form-title">
|
||||||
{selectedId ? 'Edit Nomenclature' : 'New Nomenclature'}
|
{!canEdit ? 'View Nomenclature' : selectedId ? 'Edit Nomenclature' : 'New Nomenclature'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Basic fields */}
|
{/* Basic fields */}
|
||||||
@@ -235,7 +240,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
value={form.code}
|
value={form.code}
|
||||||
onChange={e => setForm(prev => ({ ...prev, code: e.target.value }))}
|
onChange={e => setForm(prev => ({ ...prev, code: e.target.value }))}
|
||||||
placeholder="e.g. cargo_type"
|
placeholder="e.g. cargo_type"
|
||||||
disabled={isSaving}
|
disabled={isSaving || !canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -246,7 +251,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
|
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||||
placeholder="e.g. Cargo Type"
|
placeholder="e.g. Cargo Type"
|
||||||
disabled={isSaving}
|
disabled={isSaving || !canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -255,7 +260,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
<select
|
<select
|
||||||
value={form.applies_to}
|
value={form.applies_to}
|
||||||
onChange={e => setForm(prev => ({ ...prev, applies_to: e.target.value }))}
|
onChange={e => setForm(prev => ({ ...prev, applies_to: e.target.value }))}
|
||||||
disabled={isSaving}
|
disabled={isSaving || !canEdit}
|
||||||
>
|
>
|
||||||
<option value="vehicle">Vehicle</option>
|
<option value="vehicle">Vehicle</option>
|
||||||
<option value="container">Container</option>
|
<option value="container">Container</option>
|
||||||
@@ -267,7 +272,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
<select
|
<select
|
||||||
value={form.kind}
|
value={form.kind}
|
||||||
onChange={e => setForm(prev => ({ ...prev, kind: e.target.value }))}
|
onChange={e => setForm(prev => ({ ...prev, kind: e.target.value }))}
|
||||||
disabled={isSaving}
|
disabled={isSaving || !canEdit}
|
||||||
>
|
>
|
||||||
<option value="lookup">Lookup Table</option>
|
<option value="lookup">Lookup Table</option>
|
||||||
<option value="field">Custom Field</option>
|
<option value="field">Custom Field</option>
|
||||||
@@ -280,7 +285,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
<select
|
<select
|
||||||
value={form.display_field}
|
value={form.display_field}
|
||||||
onChange={e => setForm(prev => ({ ...prev, display_field: e.target.value }))}
|
onChange={e => setForm(prev => ({ ...prev, display_field: e.target.value }))}
|
||||||
disabled={isSaving}
|
disabled={isSaving || !canEdit}
|
||||||
>
|
>
|
||||||
{form.fields.filter(f => f.key).map(f => (
|
{form.fields.filter(f => f.key).map(f => (
|
||||||
<option key={f.key} value={f.key}>{f.key}</option>
|
<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">
|
||||||
<div className="nm-section-header">
|
<div className="nm-section-header">
|
||||||
<h3>Fields</h3>
|
<h3>Fields</h3>
|
||||||
<button className="nm-section-add" onClick={addField} disabled={isSaving}>
|
{canEdit && (
|
||||||
+ Add Field
|
<button className="nm-section-add" onClick={addField} disabled={isSaving || !canEdit}>
|
||||||
</button>
|
+ Add Field
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="nm-fields-table">
|
<div className="nm-fields-table">
|
||||||
@@ -314,19 +321,19 @@ function NomenclatureManager({ onClose }) {
|
|||||||
value={field.key}
|
value={field.key}
|
||||||
onChange={e => updateField(idx, 'key', e.target.value)}
|
onChange={e => updateField(idx, 'key', e.target.value)}
|
||||||
placeholder="key"
|
placeholder="key"
|
||||||
disabled={isSaving}
|
disabled={isSaving || !canEdit}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={field.label}
|
value={field.label}
|
||||||
onChange={e => updateField(idx, 'label', e.target.value)}
|
onChange={e => updateField(idx, 'label', e.target.value)}
|
||||||
placeholder="Label"
|
placeholder="Label"
|
||||||
disabled={isSaving}
|
disabled={isSaving || !canEdit}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={field.field_type}
|
value={field.field_type}
|
||||||
onChange={e => updateField(idx, 'field_type', e.target.value)}
|
onChange={e => updateField(idx, 'field_type', e.target.value)}
|
||||||
disabled={isSaving}
|
disabled={isSaving || !canEdit}
|
||||||
>
|
>
|
||||||
<option value="text">Text</option>
|
<option value="text">Text</option>
|
||||||
<option value="number">Number</option>
|
<option value="number">Number</option>
|
||||||
@@ -337,7 +344,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.required}
|
checked={field.required}
|
||||||
onChange={e => updateField(idx, 'required', e.target.checked)}
|
onChange={e => updateField(idx, 'required', e.target.checked)}
|
||||||
disabled={isSaving}
|
disabled={isSaving || !canEdit}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="nm-remove-btn"
|
className="nm-remove-btn"
|
||||||
@@ -354,7 +361,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="nm-form-actions">
|
<div className="nm-form-actions">
|
||||||
{selectedId && (
|
{canEdit && selectedId && (
|
||||||
<button
|
<button
|
||||||
className="nm-delete-btn"
|
className="nm-delete-btn"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
@@ -369,15 +376,17 @@ function NomenclatureManager({ onClose }) {
|
|||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
Cancel
|
{canEdit ? 'Cancel' : 'Close'}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="nm-save-btn"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
{isSaving ? 'Saving...' : 'Save'}
|
|
||||||
</button>
|
</button>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
className="nm-save-btn"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useNomenclatureData } from '../../contexts/NomenclatureDataContext';
|
import { useNomenclatureData } from '../../contexts/NomenclatureDataContext';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import FormOverlay from './FormOverlay';
|
import FormOverlay from './FormOverlay';
|
||||||
import NomenclatureEntryForm from './NomenclatureEntryForm';
|
import NomenclatureEntryForm from './NomenclatureEntryForm';
|
||||||
import './NomenclatureManagementOverlay.css';
|
import './NomenclatureManagementOverlay.css';
|
||||||
@@ -13,6 +14,9 @@ export default function NomenclatureManagementOverlay({
|
|||||||
initialSelection = null, // entry ID to pre-select
|
initialSelection = null, // entry ID to pre-select
|
||||||
onSelect, // called in 'select' mode when user picks entry
|
onSelect, // called in 'select' mode when user picks entry
|
||||||
}) {
|
}) {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const canEdit = !!currentUser?.can_manage_entities;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
definitions, entries,
|
definitions, entries,
|
||||||
createEntry, updateEntry, deleteEntry, toggleEntryActive,
|
createEntry, updateEntry, deleteEntry, toggleEntryActive,
|
||||||
@@ -238,9 +242,11 @@ export default function NomenclatureManagementOverlay({
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button className="nui-mgmt-add-btn" onClick={handleAddClick} type="button">
|
{canEdit && (
|
||||||
+ Add New
|
<button className="nui-mgmt-add-btn" onClick={handleAddClick} type="button">
|
||||||
</button>
|
+ Add New
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="nui-mgmt-error">{error}</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 key={f.key}>{f.label || f.key}</th>
|
||||||
))}
|
))}
|
||||||
<th className="nui-mgmt-th-status">Active</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -286,22 +292,25 @@ export default function NomenclatureManagementOverlay({
|
|||||||
<td className="nui-mgmt-td-status">
|
<td className="nui-mgmt-td-status">
|
||||||
<span
|
<span
|
||||||
className={`nui-mgmt-active-badge ${entry.is_active ? 'nui-mgmt-active-badge--active' : 'nui-mgmt-active-badge--inactive'}`}
|
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); }}
|
onClick={canEdit ? (e) => { e.stopPropagation(); setSelectedId(entry.id); toggleEntryActive(entry.id, entry.is_active); } : undefined}
|
||||||
title={entry.is_active ? 'Click to deactivate' : 'Click to activate'}
|
title={canEdit ? (entry.is_active ? 'Click to deactivate' : 'Click to activate') : undefined}
|
||||||
|
style={canEdit ? { cursor: 'pointer' } : undefined}
|
||||||
>
|
>
|
||||||
{entry.is_active ? 'Active' : 'Inactive'}
|
{entry.is_active ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="nui-mgmt-td-actions">
|
{canEdit && (
|
||||||
<button
|
<td className="nui-mgmt-td-actions">
|
||||||
className="nui-mgmt-edit-btn"
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setSelectedId(entry.id); setTimeout(() => { setFormMode('edit'); setEditData(entry.data); setShowForm(true); }, 0); }}
|
className="nui-mgmt-edit-btn"
|
||||||
title="Edit"
|
onClick={(e) => { e.stopPropagation(); setSelectedId(entry.id); setTimeout(() => { setFormMode('edit'); setEditData(entry.data); setShowForm(true); }, 0); }}
|
||||||
type="button"
|
title="Edit"
|
||||||
>
|
type="button"
|
||||||
✎
|
>
|
||||||
</button>
|
✎
|
||||||
</td>
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -316,7 +325,7 @@ export default function NomenclatureManagementOverlay({
|
|||||||
{searchTerm && ` (filtered)`}
|
{searchTerm && ` (filtered)`}
|
||||||
</div>
|
</div>
|
||||||
<div className="nui-mgmt-footer-actions">
|
<div className="nui-mgmt-footer-actions">
|
||||||
{selectedEntry && (
|
{canEdit && selectedEntry && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="nui-mgmt-delete-btn"
|
className="nui-mgmt-delete-btn"
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { DataProvider, useReportData } from './DataContext';
|
|||||||
import { BandProvider } from './BandContext';
|
import { BandProvider } from './BandContext';
|
||||||
import Toolbar from './Toolbar';
|
import Toolbar from './Toolbar';
|
||||||
import EditorCanvas from './EditorCanvas';
|
import EditorCanvas from './EditorCanvas';
|
||||||
import ConfigPanel from './ConfigPanel';
|
|
||||||
import CharacterPalette from './CharacterPalette';
|
import CharacterPalette from './CharacterPalette';
|
||||||
import ObjectInspector from './ObjectInspector';
|
import ObjectInspector from './ObjectInspector';
|
||||||
|
import { useNomenclatureData } from '../../contexts/NomenclatureDataContext';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import './ReportEditor.css';
|
import './ReportEditor.css';
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@ function ReportEditorContent() {
|
|||||||
const [toolMode, setToolMode] = useState('select');
|
const [toolMode, setToolMode] = useState('select');
|
||||||
const [borderStyle, setBorderStyle] = useState('single');
|
const [borderStyle, setBorderStyle] = useState('single');
|
||||||
const [previewMode, setPreviewMode] = useState(false);
|
const [previewMode, setPreviewMode] = useState(false);
|
||||||
const [showConfigPanel, setShowConfigPanel] = useState(false);
|
|
||||||
const [selectedChar, setSelectedChar] = useState('─');
|
const [selectedChar, setSelectedChar] = useState('─');
|
||||||
const [showCharacterPalette, setShowCharacterPalette] = useState(false);
|
const [showCharacterPalette, setShowCharacterPalette] = useState(false);
|
||||||
|
|
||||||
@@ -37,6 +36,7 @@ function ReportEditorContent() {
|
|||||||
// Auto-load report and vehicle data passed via router state
|
// Auto-load report and vehicle data passed via router state
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { setData } = useReportData();
|
const { setData } = useReportData();
|
||||||
|
const { definitions, entries, isLoaded } = useNomenclatureData();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { reportName, vehicleData } = location.state || {};
|
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) => {
|
const handleAddElement = (element) => {
|
||||||
setReport(prev => {
|
setReport(prev => {
|
||||||
// Check if the new element is positioned inside a band
|
// Check if the new element is positioned inside a band
|
||||||
@@ -332,9 +381,6 @@ function ReportEditorContent() {
|
|||||||
setSelectedElementIds([]);
|
setSelectedElementIds([]);
|
||||||
setToolMode('select');
|
setToolMode('select');
|
||||||
setShowCharacterPalette(false);
|
setShowCharacterPalette(false);
|
||||||
if (showConfigPanel) {
|
|
||||||
setShowConfigPanel(false);
|
|
||||||
}
|
|
||||||
} else if (e.key.toLowerCase() === 'd' && !e.ctrlKey && !e.metaKey && e.target.tagName !== 'INPUT') {
|
} else if (e.key.toLowerCase() === 'd' && !e.ctrlKey && !e.metaKey && e.target.tagName !== 'INPUT') {
|
||||||
handleToolChange('drawTable');
|
handleToolChange('drawTable');
|
||||||
} else if (e.key === 'Delete' && selectedElementIds.length > 0) {
|
} else if (e.key === 'Delete' && selectedElementIds.length > 0) {
|
||||||
@@ -347,7 +393,7 @@ function ReportEditorContent() {
|
|||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [showConfigPanel, selectedElementIds, toolMode, report.elements]);
|
}, [selectedElementIds, toolMode, report.elements]);
|
||||||
|
|
||||||
// Get the selected element for the Object Inspector
|
// Get the selected element for the Object Inspector
|
||||||
const selectedElement = selectedElementIds.length === 1
|
const selectedElement = selectedElementIds.length === 1
|
||||||
@@ -363,7 +409,6 @@ function ReportEditorContent() {
|
|||||||
onBorderStyleChange={setBorderStyle}
|
onBorderStyleChange={setBorderStyle}
|
||||||
previewMode={previewMode}
|
previewMode={previewMode}
|
||||||
onTogglePreview={handleTogglePreview}
|
onTogglePreview={handleTogglePreview}
|
||||||
onConfigureAPI={() => setShowConfigPanel(true)}
|
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onLoad={handleLoadClick}
|
onLoad={handleLoadClick}
|
||||||
reportName={report.name}
|
reportName={report.name}
|
||||||
@@ -393,12 +438,6 @@ function ReportEditorContent() {
|
|||||||
onUpdate={(updates) => handleElementUpdate(selectedElementIds[0], updates)}
|
onUpdate={(updates) => handleElementUpdate(selectedElementIds[0], updates)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ConfigPanel
|
|
||||||
apiEndpoint={report.apiEndpoint}
|
|
||||||
onApiEndpointChange={handleApiEndpointChange}
|
|
||||||
isOpen={showConfigPanel}
|
|
||||||
onClose={() => setShowConfigPanel(false)}
|
|
||||||
/>
|
|
||||||
{showCharacterPalette && (
|
{showCharacterPalette && (
|
||||||
<CharacterPalette
|
<CharacterPalette
|
||||||
selectedChar={selectedChar}
|
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