add staff managment permission modal window

This commit is contained in:
smanylov
2026-05-19 14:44:35 +07:00
parent 748c6cb38a
commit ced17ae4a6
10 changed files with 579 additions and 99 deletions
-2
View File
@@ -112,7 +112,6 @@ export async function authorization(
export async function logout() { export async function logout() {
const token = await getSessionData('token'); const token = await getSessionData('token');
console.log('logout');
try { try {
const response = await fetch(`${API_BASE_URL}/api/admin`, { const response = await fetch(`${API_BASE_URL}/api/admin`, {
@@ -133,7 +132,6 @@ export async function logout() {
if (response.ok) { if (response.ok) {
let parsed = await response.json(); let parsed = await response.json();
console.log(parsed);
} }
} catch (error) { } catch (error) {
throw error; throw error;
+54
View File
@@ -129,4 +129,58 @@ export async function removeStuff(id: number) {
} catch (error) { } catch (error) {
throw error; throw error;
} }
}
type Permissions = {
subscriptions: number;
mailings: number;
complaints: number;
agreements: number;
staff: number;
content_moderation: number;
users: number;
money: number;
kyc: number;
claims: number;
api: number;
dash: number;
tariffs: number;
};
export async function updateStuffPermissions(id: number, permissions: Permissions) {
const token = await getSessionData('token');
try {
const response = await fetch(`${API_BASE_URL}/api/admin`, {
method: 'POST',
body: JSON.stringify({
version: 2,
msg_id: 100,
token: token,
message_body: {
action: 'update_permissions',
admin_id: id,
permissions: permissions
}
}),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
if (response.ok) {
const parsed = await response.json();
if (parsed.message_code === 0) {
return parsed.message_body;
} else {
return null;
}
} else {
throw new Error(`${response.status}`);
}
} catch (error) {
throw error;
}
} }
+211
View File
@@ -0,0 +1,211 @@
.edit-permissions-modal {
padding: 24px;
max-width: 900px;
width: 100%;
max-height: 85vh;
overflow-y: auto;
background: white;
border-radius: 12px;
.modal-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid #f0f0f0;
.modal-title {
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
margin: 0;
}
}
.permissions-list {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 28px;
.permission-item {
/* border: 1px solid #e9ecef; */
border-radius: 12px;
transition: all 0.2s ease;
/* &:hover {
border-color: #dee2e6;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
} */
.permission-header {
/* padding: 20px; */
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
}
.permission-info {
flex: 1;
.permission-title {
font-size: 16px;
font-weight: 600;
color: #212529;
margin: 0 0 4px 0;
}
.permission-description {
font-size: 13px;
color: #6c757d;
margin: 0;
}
}
.permission-controls {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
.permission-level-buttons {
display: flex;
gap: 8px;
background: #f8f9fa;
padding: 4px;
border-radius: 8px;
.permission-level-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
color: #6c757d;
.btn-icon {
font-size: 14px;
}
.btn-text {
font-weight: 500;
}
&:hover {
background: rgba(0, 0, 0, 0.05);
}
&.active {
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&.no-access {
color: #dc3545;
background: #fff5f5;
}
&.read-only {
color: #0d6efd;
background: #f0f7ff;
}
&.full-access {
color: #198754;
background: #f0fff4;
}
}
}
}
}
}
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 8px;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.btn-secondary {
background: #6c757d;
color: white;
&:hover:not(:disabled) {
background: #5a6268;
}
}
&.btn-primary {
background: #0d6efd;
color: white;
&:hover:not(:disabled) {
background: #0b5ed7;
}
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.6s linear infinite;
}
}
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Scrollbar styling
.edit-permissions-modal::-webkit-scrollbar {
width: 8px;
}
.edit-permissions-modal::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.edit-permissions-modal::-webkit-scrollbar-thumb {
background: #888;
border-radius: 10px;
&:hover {
background: #555;
}
}
+1
View File
@@ -1,4 +1,5 @@
@use './variable.scss' as v; @use './variable.scss' as v;
@use './edit-permissions-modal.scss';
:root { :root {
--primary-color: #2563eb; --primary-color: #2563eb;
-1
View File
@@ -227,7 +227,6 @@ async function createStuffAction(
} catch (error) { } catch (error) {
const errors: Record<string, string> = {} const errors: Record<string, string> = {}
// Обработка различных ошибок от сервера
switch (error) { switch (error) {
case 'email-already-exists': case 'email-already-exists':
errors['email'] = 'email-already-exists'; errors['email'] = 'email-already-exists';
@@ -0,0 +1,126 @@
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { StaffMember } from './tanstak-stuff-table';
export function EditPermissionsModal({
staff,
setWindowClose,
onSave,
isLoading
}: {
staff: StaffMember;
setWindowClose: (state: boolean) => void;
onSave: (permissions: StaffMember['permissions']) => Promise<void>;
isLoading: boolean;
}) {
const t = useTranslations("Global");
const tPermissions = useTranslations("Permissions");
const [permissions, setPermissions] = useState<StaffMember['permissions']>(staff.permissions);
const permissionsConfig = [
{ key: 'subscriptions', label: tPermissions('subscriptions'), description: tPermissions('subscriptions-desc') },
{ key: 'mailings', label: tPermissions('mailings'), description: tPermissions('mailings-desc') },
{ key: 'complaints', label: tPermissions('complaints'), description: tPermissions('complaints-desc') },
{ key: 'agreements', label: tPermissions('agreements'), description: tPermissions('agreements-desc') },
{ key: 'staff', label: tPermissions('staff'), description: tPermissions('staff-desc') },
{ key: 'content_moderation', label: tPermissions('content_moderation'), description: tPermissions('content_moderation-desc') },
{ key: 'users', label: tPermissions('users'), description: tPermissions('users-desc') },
{ key: 'kyc', label: tPermissions('kyc'), description: tPermissions('kyc-desc') },
{ key: 'money', label: tPermissions('money'), description: tPermissions('money-desc') },
{ key: 'claims', label: tPermissions('claims'), description: tPermissions('claims-desc') },
{ key: 'api', label: tPermissions('api'), description: tPermissions('api-desc') },
{ key: 'dash', label: tPermissions('dash'), description: tPermissions('dash-desc') },
{ key: 'tariffs', label: tPermissions('tariffs'), description: tPermissions('tariffs-desc') },
];
const handlePermissionChange = (key: keyof StaffMember['permissions'], value: number) => {
setPermissions(prev => ({
...prev,
[key]: value
}));
};
const handleSave = async () => {
await onSave(permissions);
};
return (
<div className="edit-permissions-modal">
<div className="modal-header">
<h2 className="modal-title">
{tPermissions('edit-permissions')}
</h2>
</div>
<div className="permissions-list">
{permissionsConfig.map((perm) => {
const currentValue = permissions[perm.key as keyof StaffMember['permissions']];
return (
<div key={perm.key} className="permission-item">
<div className="permission-header">
<div className="permission-info">
<h3 className="permission-title">{perm.label}</h3>
{/* <p className="permission-description">{perm.description}</p> */}
</div>
<div className="permission-controls">
<div className="permission-level-buttons">
<button
type="button"
onClick={() => handlePermissionChange(perm.key as keyof StaffMember['permissions'], 0)}
className={`permission-level-btn ${currentValue === 0 ? 'active no-access' : ''}`}
title={tPermissions('no-access')}
>
<span className="btn-text">{tPermissions('no-access')}</span>
</button>
<button
type="button"
onClick={() => handlePermissionChange(perm.key as keyof StaffMember['permissions'], 1)}
className={`permission-level-btn ${currentValue === 1 ? 'active read-only' : ''}`}
title={tPermissions('read-only')}
>
<span className="btn-text">{tPermissions('read-only')}</span>
</button>
<button
type="button"
onClick={() => handlePermissionChange(perm.key as keyof StaffMember['permissions'], 3)}
className={`permission-level-btn ${currentValue === 3 ? 'active full-access' : ''}`}
title={tPermissions('full-access')}
>
<span className="btn-text">{tPermissions('full-access')}</span>
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
<div className="modal-footer">
<button
onClick={() => setWindowClose(false)}
className="btn btn-secondary"
disabled={isLoading}
>
{t('cancel')}
</button>
<button
onClick={handleSave}
className="btn btn-primary"
disabled={isLoading}
>
{isLoading ? (
<>
<span className="spinner"></span>
{t('saving')}
</>
) : t('save-changes')}
</button>
</div>
</div>
);
}
@@ -0,0 +1,69 @@
import { ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { StaffMember } from '@/app/ui/staff-management/tanstak-stuff-table';
import { formatDate, formatDateTime } from '@/app/lib/formatDate';
export function StaffInfoModal({
staff,
setWindowClose,
setWindowChildren
}: {
staff: StaffMember;
setWindowClose: (state: boolean) => void;
setWindowChildren: (children: ReactNode) => void;
}) {
const t = useTranslations("Global");
return (
<div className="p-6 max-w-2xl">
<h2 className="text-2xl font-bold mb-4">Информация о сотруднике</h2>
<div className="space-y-4">
<div>
<strong>ID:</strong> {staff.id}
</div>
<div>
<strong>ФИО:</strong> {staff.full_name}
</div>
<div>
<strong>Email:</strong> {staff.email}
</div>
<div>
<strong>Статус:</strong> {staff.is_active ? 'Активен' : 'Неактивен'}
</div>
<div>
<strong>Роль:</strong> {staff.is_super_admin ? 'Super Admin' : 'Staff'}
</div>
<div>
<strong>Дата создания:</strong> {formatDateTime(staff.created_at)} {formatDate(staff.created_at)}
</div>
<div>
<strong>Активный пароль:</strong> {staff.active_password ? 'Да' : 'Нет'}
</div>
<div>
<strong className="block mb-2">Права доступа:</strong>
<div className="grid grid-cols-2 gap-2 bg-gray-50 p-3 rounded">
{Object.entries(staff.permissions).map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="capitalize">{key}:</span>
<span className="font-semibold">
{value === 1 ? 'Чтение' : value === 2 ? 'Запись' : value === 3 ? 'Полный доступ' : value}
</span>
</div>
))}
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setWindowClose(false)}
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
>
{t('close')}
</button>
</div>
</div>
);
}
@@ -19,9 +19,12 @@ import ModalWindow from '@/app/components/modalWindow';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { useStuffList } from '@/app/hooks/react-query/stuff/useStuffList'; import { useStuffList } from '@/app/hooks/react-query/stuff/useStuffList';
import { removeStuff } from '@/app/actions/stuffActions'; import { removeStuff, updateStuffPermissions } from '@/app/actions/stuffActions';
import { EditPermissionsModal } from './EditPermissionsModal'
import { StaffInfoModal } from '@/app/ui/staff-management/StaffInfoModal';
import { formatDate, formatDateTime } from '@/app/lib/formatDate';
interface StaffMember { export interface StaffMember {
id: number; id: number;
full_name: string; full_name: string;
email: string; email: string;
@@ -46,24 +49,6 @@ interface StaffMember {
}; };
} }
// Форматирование даты из ISO строки
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
return `${day}.${month}.${year}`;
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
};
export default function TanstakStuffTable() { export default function TanstakStuffTable() {
// Состояния // Состояния
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -128,10 +113,6 @@ export default function TanstakStuffTable() {
const totalItems = staffList?.length || 0; const totalItems = staffList?.length || 0;
const totalPages = Math.ceil(totalItems / pagination.pageSize); const totalPages = Math.ceil(totalItems / pagination.pageSize);
useEffect(() => {
console.log(staffList);
}, [staffList])
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (id: number) => removeStuff(id), mutationFn: (id: number) => removeStuff(id),
@@ -147,6 +128,19 @@ export default function TanstakStuffTable() {
} }
}); });
const updatePermissionsMutation = useMutation({
mutationFn: ({ id, permissions }: { id: number; permissions: StaffMember['permissions'] }) =>
updateStuffPermissions(id, permissions),
onSuccess: () => {
toast.success(tStaff('permissions-updated-successfully'));
queryClient.invalidateQueries({ queryKey: ['stuffList'] });
setOpenWindow(false);
},
onError: (error: Error) => {
toast.error(tStaff(error.message || 'permissions-update-failed'));
}
});
// Определение колонок для сотрудников // Определение колонок для сотрудников
const columns = useMemo<ColumnDef<StaffMember>[]>( const columns = useMemo<ColumnDef<StaffMember>[]>(
() => [ () => [
@@ -308,6 +302,13 @@ export default function TanstakStuffTable() {
> >
<IconEye /> <IconEye />
</button> </button>
<button
onClick={() => handleEditPermissions(row.original)}
className="bg-green-500 hover:bg-green-600"
title="Редактировать права доступа"
>
<IconShieldExclamation />
</button>
<button <button
onClick={() => handleRemove(row.original.id, row.original.full_name)} onClick={() => handleRemove(row.original.id, row.original.full_name)}
className="bg-red-500 hover:bg-red-600" className="bg-red-500 hover:bg-red-600"
@@ -343,13 +344,28 @@ export default function TanstakStuffTable() {
setConfirmRemove({ id, name }); setConfirmRemove({ id, name });
}; };
// Фильтрация данных (если нужна клиентская фильтрация) const handleEditPermissions = (staff: StaffMember) => {
setSelectedStaff(staff);
setOpenWindowChildren(() => {
return (
<EditPermissionsModal
staff={staff}
setWindowClose={setOpenWindow}
onSave={async (permissions) => {
await updatePermissionsMutation.mutateAsync({ id: staff.id, permissions });
}}
isLoading={updatePermissionsMutation.isPending}
/>
)
})
setOpenWindow(true);
};
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!staffList) { if (!staffList) {
return []; return [];
} }
// Если серверная фильтрация уже применена, возвращаем как есть
return staffList; return staffList;
}, [staffList]); }, [staffList]);
@@ -586,70 +602,4 @@ export default function TanstakStuffTable() {
</div> </div>
</div> </div>
); );
}
// Компонент модального окна с информацией о сотруднике
function StaffInfoModal({
staff,
setWindowClose,
setWindowChildren
}: {
staff: StaffMember;
setWindowClose: (state: boolean) => void;
setWindowChildren: (children: ReactNode) => void;
}) {
const t = useTranslations("Global");
return (
<div className="p-6 max-w-2xl">
<h2 className="text-2xl font-bold mb-4">Информация о сотруднике</h2>
<div className="space-y-4">
<div>
<strong>ID:</strong> {staff.id}
</div>
<div>
<strong>ФИО:</strong> {staff.full_name}
</div>
<div>
<strong>Email:</strong> {staff.email}
</div>
<div>
<strong>Статус:</strong> {staff.is_active ? 'Активен' : 'Неактивен'}
</div>
<div>
<strong>Роль:</strong> {staff.is_super_admin ? 'Super Admin' : 'Staff'}
</div>
<div>
<strong>Дата создания:</strong> {formatDateTime(staff.created_at)} {formatDate(staff.created_at)}
</div>
<div>
<strong>Активный пароль:</strong> {staff.active_password ? 'Да' : 'Нет'}
</div>
<div>
<strong className="block mb-2">Права доступа:</strong>
<div className="grid grid-cols-2 gap-2 bg-gray-50 p-3 rounded">
{Object.entries(staff.permissions).map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="capitalize">{key}:</span>
<span className="font-semibold">
{value === 1 ? 'Чтение' : value === 2 ? 'Запись' : value === 3 ? 'Полный доступ' : value}
</span>
</div>
))}
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setWindowClose(false)}
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
>
{t('close')}
</button>
</div>
</div>
);
} }
+38 -2
View File
@@ -188,7 +188,9 @@
"storageInformation": "Storage Information", "storageInformation": "Storage Information",
"diskUsage": "Disk Usage", "diskUsage": "Disk Usage",
"diskUsagePercent": "Usage", "diskUsagePercent": "Usage",
"fileCount": "Files" "fileCount": "Files",
"employee": "Employee",
"role": "Role"
}, },
"Login-register-form": { "Login-register-form": {
"and": "and", "and": "and",
@@ -269,6 +271,40 @@
"passwords-do-not-match": "Passwords do not match", "passwords-do-not-match": "Passwords do not match",
"email-already-exists": "User with this email already exists", "email-already-exists": "User with this email already exists",
"staff-removed-successfully": "The employee has been successfully removed", "staff-removed-successfully": "The employee has been successfully removed",
"removal-failed": "Error deleting employee" "removal-failed": "Error deleting employee",
"permissions-updated-successfully": "Permissions updated successfully",
"permissions-update-failed": "Failed to update permissions"
},
"Permissions": {
"edit-permissions": "Edit Permissions",
"no-access": "No Access",
"read-only": "Read Only",
"full-access": "Full Access",
"subscriptions": "Subscriptions",
"subscriptions-desc": "Manage user subscriptions",
"mailings": "Mailings",
"mailings-desc": "Manage email mailings",
"complaints": "Complaints",
"complaints-desc": "Process user complaints",
"agreements": "Agreements",
"agreements-desc": "Manage user agreements",
"staff": "Staff",
"staff-desc": "Manage staff members",
"content_moderation": "Content Moderation",
"content_moderation-desc": "Moderate content",
"users": "Users",
"users-desc": "Manage users",
"kyc": "Verification",
"kyc-desc": "Manage user verification",
"money": "Money",
"money-desc": "Manage financial operations",
"claims": "Claims",
"claims-desc": "Process claims",
"api": "API",
"api-desc": "Manage API access",
"dash": "Dashboard",
"dash-desc": "Access to dashboard",
"tariffs": "Tariffs",
"tariffs-desc": "Manage tariffs"
} }
} }
+38 -2
View File
@@ -188,7 +188,9 @@
"storageInformation": "Информация о хранилище", "storageInformation": "Информация о хранилище",
"diskUsage": "Использование диска", "diskUsage": "Использование диска",
"diskUsagePercent": "Заполнение", "diskUsagePercent": "Заполнение",
"fileCount": "Файлы" "fileCount": "Файлы",
"employee": "Сотрудник",
"role": "Роль"
}, },
"Login-register-form": { "Login-register-form": {
"and": "и", "and": "и",
@@ -269,6 +271,40 @@
"passwords-do-not-match": "Пароли не совпадают", "passwords-do-not-match": "Пароли не совпадают",
"email-already-exists": "Пользователь с таким адресом электронной почты уже существует", "email-already-exists": "Пользователь с таким адресом электронной почты уже существует",
"staff-removed-successfully": "Сотрудник успешно удален", "staff-removed-successfully": "Сотрудник успешно удален",
"removal-failed": "Ошибка при удалении сотрудника" "removal-failed": "Ошибка при удалении сотрудника",
"permissions-updated-successfully": "Права доступа успешно обновлены",
"permissions-update-failed": "Не удалось обновить права доступа"
},
"Permissions": {
"edit-permissions": "Редактирование прав доступа",
"no-access": "Нет доступа",
"read-only": "Только чтение",
"full-access": "Полный доступ",
"subscriptions": "Подписки",
"subscriptions-desc": "Управление подписками пользователей",
"mailings": "Рассылки",
"mailings-desc": "Управление email рассылками",
"complaints": "Жалобы",
"complaints-desc": "Обработка жалоб пользователей",
"agreements": "Соглашения",
"agreements-desc": "Управление пользовательскими соглашениями",
"staff": "Сотрудники",
"staff-desc": "Управление сотрудниками",
"content_moderation": "Модерация контента",
"content_moderation-desc": "Модерация контента",
"users": "Пользователи",
"users-desc": "Управление пользователями",
"kyc": "Верификация",
"kyc-desc": "Управление верификацией пользователей",
"money": "Финансы",
"money-desc": "Управление финансовыми операциями",
"claims": "Претензии",
"claims-desc": "Обработка претензий",
"api": "API",
"api-desc": "Управление API доступами",
"dash": "Панель управления",
"dash-desc": "Доступ к панеле управления",
"tariffs": "Тарифы",
"tariffs-desc": "Управление тарифами"
} }
} }