add stuff table, add stuff remove and add interfaces
This commit is contained in:
@@ -2,6 +2,7 @@ import Providers from '@/app/providers/getQueryServer'
|
||||
import { NextIntlClientProvider, hasLocale } from 'next-intl';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import { ToastProvider } from '@/app/providers/ToastProvider';
|
||||
|
||||
import "../styles/globals.css";
|
||||
import "../styles/global-styles.scss"
|
||||
@@ -28,6 +29,7 @@ export default async function RootLayout({
|
||||
<NextIntlClientProvider>
|
||||
<Providers>
|
||||
{children}
|
||||
<ToastProvider />
|
||||
</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useStuffList } from '@/app/hooks/react-query/stuff/useStuffList';
|
||||
import { useEffect } from 'react';
|
||||
import TanstakStuffTable from '@/app/ui/staff-management/tanstak-stuff-table';
|
||||
import CreateStaffForm from '@/app/ui/forms/create-staff-form';
|
||||
import StaffManagement from '@/app/ui/staff-management/staff-add-management';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<StaffManagement />
|
||||
<TanstakStuffTable />
|
||||
{/* <CreateStaffForm /> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
'use server'
|
||||
|
||||
import { getSessionData } from '@/app/actions/session';
|
||||
import { API_BASE_URL } from '@/app/actions/definitions';
|
||||
|
||||
export async function fetchStuffList(page?: number, size?: number, sortBy?: string, sortDirection?: 'asc' | 'desc' | string, nameQuery?: string) {
|
||||
const token = await getSessionData('token');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/admin`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
version: 1,
|
||||
msg_id: 100,
|
||||
token: token,
|
||||
message_body: {
|
||||
/* token: token, */
|
||||
action: 'get_all',
|
||||
page: page,
|
||||
size: size,
|
||||
sort_by: sortBy || '',
|
||||
sort_direction: sortDirection || 'asc',
|
||||
full_name: nameQuery
|
||||
}
|
||||
}),
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createStuff(fullName: string, email: string, password: string) {
|
||||
const token = await getSessionData('token');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/admin`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
version: 1,
|
||||
msg_id: 100,
|
||||
token: token,
|
||||
message_body: {
|
||||
action: 'create',
|
||||
full_name: fullName,
|
||||
email: email,
|
||||
password: password,
|
||||
is_super_admin: false
|
||||
}
|
||||
}),
|
||||
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 if (parsed.message_code === 3) {
|
||||
throw new Error('email-already-exists');
|
||||
} else if (parsed.message_code === 2) {
|
||||
throw new Error('invalid-email');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeStuff(id: number) {
|
||||
const token = await getSessionData('token');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/admin`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
version: 1,
|
||||
msg_id: 100,
|
||||
token: token,
|
||||
message_body: {
|
||||
action: 'delete',
|
||||
admin_id: id,
|
||||
}
|
||||
}),
|
||||
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 if (parsed.message_code === 3) {
|
||||
throw new Error('email-already-exists');
|
||||
} else if (parsed.message_code === 2) {
|
||||
throw new Error('invalid-email');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchStuffList } from '@/app/actions/stuffActions';
|
||||
|
||||
interface StaffMember {
|
||||
id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_super_admin: boolean;
|
||||
created_at: string;
|
||||
active_password: boolean;
|
||||
permissions: {
|
||||
subscriptions: number;
|
||||
mailings: number;
|
||||
complaints: number;
|
||||
agreements: number;
|
||||
staff: number;
|
||||
content_moderation: number;
|
||||
users: number;
|
||||
kyc: number;
|
||||
money: number;
|
||||
claims: number;
|
||||
api: number;
|
||||
dash: number;
|
||||
tariffs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const useStuffList = (page?: number, size?: number, sortBy?: string, sortDirection?: 'asc' | 'desc' | string, nameQuery?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['stuffList'],
|
||||
queryFn: () => {
|
||||
return fetchStuffList(page, size, sortBy, sortDirection, nameQuery)
|
||||
},
|
||||
select: (data: StaffMember[] | null) => {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
return data;
|
||||
},
|
||||
retry: false
|
||||
});
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
--success-color: #10b981;
|
||||
--success-hover: #0d8a60;
|
||||
--danger-color: #ef4444;
|
||||
--danger-hover: #bd3434;
|
||||
--warning-color: #f59e0b;
|
||||
--warning-hover: #b3750b;
|
||||
--info-color: #3b82f6;
|
||||
@@ -93,29 +94,29 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.show-password-button {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0px;
|
||||
padding: 10px;
|
||||
transform: translateY(-50%);
|
||||
.show-password-button {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0px;
|
||||
padding: 10px;
|
||||
transform: translateY(-50%);
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
color: var(--secondary-hover);
|
||||
transition: color 0.2s ease;
|
||||
svg {
|
||||
width: 16px;
|
||||
color: var(--secondary-hover);
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.show {
|
||||
svg {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
&.show {
|
||||
svg {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,6 +286,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import styles from '@/app/styles/module/login.module.scss'
|
||||
import { useActionState, useState, useRef, useEffect } from 'react';
|
||||
import { createStuff } from '@/app/actions/stuffActions';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { IconEye } from '@/app/ui/icons/icons';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const createStuffSchema = z.object({
|
||||
fullName: z.string()
|
||||
.min(2, 'full-name-too-short')
|
||||
.max(100, 'full-name-too-long')
|
||||
.trim(),
|
||||
email: z.string()
|
||||
.email('invalid-email')
|
||||
.toLowerCase()
|
||||
.trim(),
|
||||
password: z.string()
|
||||
.min(6, 'password-too-short')
|
||||
.max(50, 'password-too-long'),
|
||||
})
|
||||
|
||||
export default function CreateStaffForm({ onSuccess }: { onSuccess?: () => void }) {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
createStuffAction,
|
||||
undefined,
|
||||
);
|
||||
const t = useTranslations('Login-register-form');
|
||||
const tStaff = useTranslations('Staff');
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
function toggleShowPassword() {
|
||||
setShowPassword(!showPassword);
|
||||
}
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.success) {
|
||||
toast.success(tStaff('staff-created-successfully'));
|
||||
queryClient.invalidateQueries({ queryKey: ['stuffList'] });
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
if (state?.error?.server) {
|
||||
toast.error(t(state.error.server));
|
||||
}
|
||||
}, [state, queryClient, onSuccess, t, tStaff]);
|
||||
|
||||
return (
|
||||
<form
|
||||
className={`${styles['form-wrapper']}`}
|
||||
action={formAction}
|
||||
autoComplete="off"
|
||||
>
|
||||
{/* Поле Email */}
|
||||
<div className={`${styles['form-group']}`}>
|
||||
<label className={`${styles['form-label']}`}>
|
||||
{t('email-adress')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
className={`${styles['form-input']}`}
|
||||
placeholder={t('enter-email')}
|
||||
defaultValue={state?.previousState?.email || ''}
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.toLowerCase();
|
||||
}}
|
||||
/>
|
||||
{state?.error?.email && (
|
||||
<p className={`${styles['form-error']}`}>
|
||||
{t(state?.error?.email)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Поле Password */}
|
||||
<div className={`${styles['form-group']}`}>
|
||||
<label className={`${styles['form-label']}`}>
|
||||
{t('password')}
|
||||
</label>
|
||||
<div className={`${styles['password-wrapper']}`}>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
id="password"
|
||||
name="password"
|
||||
className={`${styles['form-input']} ${styles['password']}`}
|
||||
placeholder={t('enter-password')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
onClick={toggleShowPassword}
|
||||
type="button"
|
||||
className={`show-password-button ${showPassword ? 'show' : ''}`}
|
||||
>
|
||||
<IconEye />
|
||||
</button>
|
||||
</div>
|
||||
{state?.error?.password && (
|
||||
<p className={`${styles['form-error']}`}>
|
||||
{t(state?.error?.password)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Поле Full Name */}
|
||||
<div className={`${styles['form-group']}`}>
|
||||
<label className={`${styles['form-label']}`}>
|
||||
{tStaff('full-name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
className={`${styles['form-input']}`}
|
||||
placeholder={tStaff('enter-full-name')}
|
||||
defaultValue={state?.previousState?.fullName || ''}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{state?.error?.fullName && (
|
||||
<p className={`${styles['form-error']}`}>
|
||||
{t(state?.error?.fullName)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Общая ошибка сервера */}
|
||||
{state?.error?.server && !state.success && (
|
||||
<p className={`${styles['form-error']}`}>
|
||||
{t(state?.error?.server)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Кнопка отправки */}
|
||||
<button
|
||||
type="submit"
|
||||
className={`${styles['btn']}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
{tStaff('create-staff')}
|
||||
{isPending && (
|
||||
<div className="loading-animation">
|
||||
<div className="global-spinner"></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
async function createStuffAction(
|
||||
state: {
|
||||
previousState: {
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
error: Record<string, string>
|
||||
success?: boolean;
|
||||
} | undefined,
|
||||
formData: FormData,
|
||||
) {
|
||||
const fullName = formData.get('fullName') as string || '';
|
||||
const email = formData.get('email') as string || '';
|
||||
const password = formData.get('password') as string || '';
|
||||
|
||||
const validatedFields = createStuffSchema.safeParse({
|
||||
fullName,
|
||||
email,
|
||||
password
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
const errors: Record<string, string> = {}
|
||||
JSON.parse(validatedFields.error.message).forEach((obj: {
|
||||
message: string,
|
||||
path: string[]
|
||||
}) => {
|
||||
const fieldName = obj.path[0];
|
||||
if (errors[fieldName]) {
|
||||
errors[fieldName] = `${errors[fieldName]} ${obj.message}`;
|
||||
} else {
|
||||
errors[fieldName] = obj.message;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
previousState: {
|
||||
fullName,
|
||||
email,
|
||||
password
|
||||
},
|
||||
error: errors,
|
||||
success: false
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { fullName, email, password } = validatedFields.data;
|
||||
|
||||
// Вызов API для создания сотрудника
|
||||
const result = await createStuff(fullName, email, password);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
previousState: {
|
||||
fullName: '',
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
error: {},
|
||||
success: true
|
||||
};
|
||||
} else {
|
||||
throw new Error('creation-failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
// Обработка различных ошибок от сервера
|
||||
switch (error) {
|
||||
case 'email-already-exists':
|
||||
errors['email'] = 'email-already-exists';
|
||||
break;
|
||||
case 'invalid-email':
|
||||
errors['email'] = 'invalid-email';
|
||||
break;
|
||||
default:
|
||||
errors['server'] = 'request-ended-with-an-error';
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
previousState: {
|
||||
fullName,
|
||||
email,
|
||||
password
|
||||
},
|
||||
error: errors,
|
||||
success: false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ export default function AdminNavLinks() {
|
||||
href: '/pages/users',
|
||||
img: 'M16 17v2H2v-2s0-4 7-4s7 4 7 4m-3.5-9.5A3.5 3.5 0 1 0 9 11a3.5 3.5 0 0 0 3.5-3.5m3.44 5.5A5.32 5.32 0 0 1 18 17v2h4v-2s0-3.63-6.06-4M15 4a3.4 3.4 0 0 0-1.93.59a5 5 0 0 1 0 5.82A3.4 3.4 0 0 0 15 11a3.5 3.5 0 0 0 0-7'
|
||||
},
|
||||
{
|
||||
name: 'staff management',
|
||||
href: '/pages/staff-management',
|
||||
img: 'M16 17v2H2v-2s0-4 7-4s7 4 7 4m-3.5-9.5A3.5 3.5 0 1 0 9 11a3.5 3.5 0 0 0 3.5-3.5m3.44 5.5A5.32 5.32 0 0 1 18 17v2h4v-2s0-3.63-6.06-4M15 4a3.4 3.4 0 0 0-1.93.59a5 5 0 0 1 0 5.82A3.4 3.4 0 0 0 15 11a3.5 3.5 0 0 0 0-7'
|
||||
},
|
||||
{
|
||||
name: 'subscriptions',
|
||||
href: '/pages/subscriptions',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import ModalWindow from '@/app/components/modalWindow';
|
||||
import CreateStaffForm from '@/app/ui/forms/create-staff-form';
|
||||
|
||||
export default function StaffManagement() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="admin-content mb-4"
|
||||
>
|
||||
<div
|
||||
className="p-4"
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Создать сотрудника
|
||||
</button>
|
||||
|
||||
<ModalWindow state={isModalOpen} callBack={setIsModalOpen}>
|
||||
<div className="p-6 min-w-[400px]">
|
||||
<h2 className="text-2xl font-bold mb-4">Создание сотрудника</h2>
|
||||
<CreateStaffForm onSuccess={() => setIsModalOpen(false)} />
|
||||
</div>
|
||||
</ModalWindow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useEffect, ReactNode, useCallback } from 'react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getFilteredRowModel,
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
ColumnFiltersState,
|
||||
} from '@tanstack/react-table';
|
||||
import { IconEye, IconDoubleArrowRight, IconArrowRight, IconDoubleArrowLeft, IconArrowLeft, IconArrowUp, IconArrowDown, IconFilter, IconFileDownload, IconShieldExclamation, IconDelete } from '@/app/ui/icons/icons';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import DropDownList from '@/app/components/dropDownList';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import ModalWindow from '@/app/components/modalWindow';
|
||||
import { toast } from 'sonner';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useStuffList } from '@/app/hooks/react-query/stuff/useStuffList';
|
||||
import { removeStuff } from '@/app/actions/stuffActions';
|
||||
|
||||
interface StaffMember {
|
||||
id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_super_admin: boolean;
|
||||
created_at: string;
|
||||
active_password: boolean;
|
||||
permissions: {
|
||||
subscriptions: number;
|
||||
mailings: number;
|
||||
complaints: number;
|
||||
agreements: number;
|
||||
staff: number;
|
||||
content_moderation: number;
|
||||
users: number;
|
||||
kyc: number;
|
||||
money: number;
|
||||
claims: number;
|
||||
api: number;
|
||||
dash: number;
|
||||
tariffs: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Форматирование даты из 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() {
|
||||
// Состояния
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [searchInputValue, setSearchInputValue] = useState<string>('');
|
||||
const [openWindow, setOpenWindow] = useState<boolean>(false);
|
||||
const [openWindowChildren, setOpenWindowChildren] = useState<ReactNode>(null);
|
||||
const [selectedStaff, setSelectedStaff] = useState<StaffMember | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState<{ id: number; name: string } | null>(null);
|
||||
const tStaff = useTranslations('Staff');
|
||||
|
||||
const debouncedSetSearchQuery = useDebouncedCallback(
|
||||
(value: string) => {
|
||||
setSearchQuery(value);
|
||||
setPagination(prev => ({ ...prev, pageIndex: 0 }));
|
||||
},
|
||||
500
|
||||
);
|
||||
|
||||
const handleSearchInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setSearchInputValue(value);
|
||||
debouncedSetSearchQuery(value);
|
||||
}, [debouncedSetSearchQuery]);
|
||||
|
||||
const t = useTranslations("Global");
|
||||
const locale = useLocale();
|
||||
|
||||
const getSortParams = useMemo(() => {
|
||||
if (sorting.length === 0) {
|
||||
return { sortBy: '', sortOrder: undefined };
|
||||
}
|
||||
|
||||
const sort = sorting[0];
|
||||
const sortByMap: Record<string, string> = {
|
||||
'id': 'id',
|
||||
'full_name': 'full_name',
|
||||
'email': 'email',
|
||||
'is_active': 'is_active',
|
||||
'is_super_admin': 'is_super_admin',
|
||||
'created_at': 'created_at',
|
||||
};
|
||||
|
||||
return {
|
||||
sortBy: sortByMap[sort.id] || sort.id,
|
||||
sortDirection: sort.desc ? 'desc' : 'asc'
|
||||
};
|
||||
}, [sorting]);
|
||||
|
||||
const { data: staffList,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
error
|
||||
} = useStuffList(pagination.pageIndex + 1, pagination.pageSize, getSortParams?.sortBy, getSortParams?.sortDirection, searchQuery);
|
||||
|
||||
const totalItems = staffList?.length || 0;
|
||||
const totalPages = Math.ceil(totalItems / pagination.pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(staffList);
|
||||
}, [staffList])
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: number) => removeStuff(id),
|
||||
onSuccess: () => {
|
||||
toast.success(tStaff('staff-removed-successfully'));
|
||||
// Инвалидируем кэш списка сотрудников
|
||||
queryClient.invalidateQueries({ queryKey: ['stuffList'] });
|
||||
setConfirmRemove(null); // Закрываем окно подтверждения
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(tStaff(error.message || 'removal-failed'));
|
||||
setConfirmRemove(null);
|
||||
}
|
||||
});
|
||||
|
||||
// Определение колонок для сотрудников
|
||||
const columns = useMemo<ColumnDef<StaffMember>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: ({ column }) => (
|
||||
<div className="column start">
|
||||
<span>ID</span>
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="sort-button"
|
||||
>
|
||||
{column.getIsSorted() === 'asc' ? <IconArrowUp /> : column.getIsSorted() === 'desc' ? <IconArrowDown /> : <IconFilter />}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center table-item table-item-id">
|
||||
<div>
|
||||
<div className="font-medium w-full truncate" title={row.original.id.toString()}>
|
||||
{row.original.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableColumnFilter: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'full_name',
|
||||
header: ({ column }) => (
|
||||
<div className="column">
|
||||
<span>Пользователь</span>
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="sort-button"
|
||||
>
|
||||
{column.getIsSorted() === 'asc' ? <IconArrowUp /> : column.getIsSorted() === 'desc' ? <IconArrowDown /> : <IconFilter />}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className={`text-center font-semibold`}>
|
||||
{row.original.full_name}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: ({ column }) => (
|
||||
<div className="column">
|
||||
<span>Email</span>
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="sort-button"
|
||||
>
|
||||
{column.getIsSorted() === 'asc' ? <IconArrowUp /> : column.getIsSorted() === 'desc' ? <IconArrowDown /> : <IconFilter />}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className={`text-center font-semibold text-green-600`}>
|
||||
{row.original.email}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: ({ column }) => (
|
||||
<div className="column">
|
||||
<span>Статус</span>
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="sort-button"
|
||||
>
|
||||
{column.getIsSorted() === 'asc' ? <IconArrowUp /> : column.getIsSorted() === 'desc' ? <IconArrowDown /> : <IconFilter />}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${row.original.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{row.original.is_active ? 'Активен' : 'Неактивен'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_super_admin',
|
||||
header: ({ column }) => (
|
||||
<div className="column">
|
||||
<span>Роль</span>
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="sort-button"
|
||||
>
|
||||
{column.getIsSorted() === 'asc' ? <IconArrowUp /> : column.getIsSorted() === 'desc' ? <IconArrowDown /> : <IconFilter />}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${row.original.is_super_admin ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800'}`}>
|
||||
{row.original.is_super_admin ? 'Super Admin' : 'Staff'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<div className="column">
|
||||
<span>Дата создания</span>
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="sort-button"
|
||||
>
|
||||
{column.getIsSorted() === 'asc' ? <IconArrowUp /> : column.getIsSorted() === 'desc' ? <IconArrowDown /> : <IconFilter />}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className={`text-center font-semibold`}>
|
||||
{row.original.created_at ? (
|
||||
<>
|
||||
{formatDate(row.original.created_at)}
|
||||
<br />
|
||||
{formatDateTime(row.original.created_at)}
|
||||
</>
|
||||
) : (
|
||||
<div>-</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => {
|
||||
return (
|
||||
<div className="column">
|
||||
{t('actions')}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="actions">
|
||||
<div className="actions-group">
|
||||
<button
|
||||
onClick={() => handleView(row.original)}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
title="Просмотр прав доступа"
|
||||
>
|
||||
<IconEye />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemove(row.original.id, row.original.full_name)}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
title="Удалить сотрудника"
|
||||
>
|
||||
<IconDelete />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableColumnFilter: false,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleView = (staff: StaffMember) => {
|
||||
setSelectedStaff(staff);
|
||||
setOpenWindowChildren(() => {
|
||||
return (
|
||||
<StaffInfoModal
|
||||
staff={staff}
|
||||
setWindowClose={setOpenWindow}
|
||||
setWindowChildren={setOpenWindowChildren}
|
||||
/>
|
||||
)
|
||||
})
|
||||
setOpenWindow(true);
|
||||
};
|
||||
|
||||
const handleRemove = (id: number, name: string) => {
|
||||
setConfirmRemove({ id, name });
|
||||
};
|
||||
|
||||
// Фильтрация данных (если нужна клиентская фильтрация)
|
||||
const filteredData = useMemo(() => {
|
||||
if (!staffList) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Если серверная фильтрация уже применена, возвращаем как есть
|
||||
return staffList;
|
||||
}, [staffList]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentPageRows = table.getRowModel().rows;
|
||||
const pageCount = table.getPageCount();
|
||||
|
||||
if (currentPageRows.length === 0 && pagination.pageIndex > 0 && pageCount > 0) {
|
||||
table.setPageIndex(pagination.pageIndex - 1);
|
||||
}
|
||||
}, [filteredData, pagination.pageIndex]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
pagination
|
||||
},
|
||||
manualPagination: false,
|
||||
manualSorting: false,
|
||||
manualFiltering: false,
|
||||
pageCount: totalPages || 0,
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: (updater) => {
|
||||
const newSorting = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
setSorting(newSorting);
|
||||
setPagination(prev => ({ ...prev, pageIndex: 0 }));
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel()
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="tanstak-table-wrapper">
|
||||
<div className="error-state text-center p-8">
|
||||
<p className="text-red-600">Ошибка загрузки данных: {error?.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="admin-content"
|
||||
>
|
||||
<div className="tanstak-table-wrapper">
|
||||
<ModalWindow state={openWindow} callBack={setOpenWindow}>
|
||||
{openWindowChildren}
|
||||
</ModalWindow>
|
||||
|
||||
<ModalWindow
|
||||
state={!!confirmRemove}
|
||||
callBack={() => setConfirmRemove(null)}
|
||||
>
|
||||
{confirmRemove && (
|
||||
<div className="p-6 max-w-md">
|
||||
<h2 className="text-2xl font-bold mb-4">Подтверждение удаления</h2>
|
||||
<p className="mb-6">
|
||||
Вы уверены, что хотите удалить сотрудника <strong>{confirmRemove.name}</strong>?
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setConfirmRemove(null)}
|
||||
className="btn btn-primary"
|
||||
disabled={removeMutation.isPending}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeMutation.mutate(confirmRemove.id)}
|
||||
className="btn btn-danger"
|
||||
disabled={removeMutation.isPending}
|
||||
>
|
||||
{removeMutation.isPending ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalWindow>
|
||||
|
||||
{/* Фильтры */}
|
||||
<div className="tanstak-table-filtres">
|
||||
{/* <div className="table-filtres-wrapper">
|
||||
<div className="table-filtres-item">
|
||||
<div className="table-filtres-label">{t('search')}:</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchInputValue}
|
||||
onChange={handleSearchInputChange}
|
||||
placeholder="Поиск сотрудников по имени или email"
|
||||
className="table-filtres-input"
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div className="table-filtres-item">
|
||||
<div className="table-filtres-label">{t('items-per-page')}:</div>
|
||||
<DropDownList
|
||||
value={table.getState().pagination.pageSize}
|
||||
//@ts-ignore сделал так потому что для table.setPageSize нужна цифра
|
||||
callBack={table.setPageSize}
|
||||
>
|
||||
{[5, 10, 20, 50, 100].map(pageSize => (
|
||||
<li key={pageSize} value={pageSize}>
|
||||
{t('show')} {pageSize}
|
||||
</li>
|
||||
))}
|
||||
</DropDownList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица */}
|
||||
<div className="tanstak-table-block">
|
||||
<table className="tanstak-table">
|
||||
<thead className="tanstak-table-head">
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map(header => (
|
||||
<th
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: typeof header.column.columnDef.header === 'function'
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header as string}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className={`tanstak-table-body ${isFetching ? 'loading' : ''}`}>
|
||||
{table.getRowModel().rows.length > 0 ? (
|
||||
table.getRowModel().rows.map(row => (
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
<td key={cell.id}>
|
||||
{typeof cell.column.columnDef.cell === 'function'
|
||||
? cell.column.columnDef.cell(cell.getContext())
|
||||
: cell.getValue() as string}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center">
|
||||
{t('no-data-for-selected-filters')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Пагинация */}
|
||||
<div className="tanstak-table-pagination">
|
||||
<div className="pagination-info">
|
||||
<span className="pagination-info-pages">
|
||||
{t('page')}{' '}
|
||||
<strong>
|
||||
{(totalPages ?? 0)} {t('out-of')} {totalPages ?? 1}
|
||||
</strong>
|
||||
</span>
|
||||
<span className="pagination-info-files">
|
||||
| {t('shown')} {table.getRowModel().rows.length} {t('out-of')} {totalItems ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pagination-controls">
|
||||
<button
|
||||
className="arrow"
|
||||
onClick={() => table.firstPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<IconDoubleArrowLeft />
|
||||
</button>
|
||||
<button
|
||||
className="arrow"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<IconArrowLeft />
|
||||
</button>
|
||||
|
||||
<div className="pagination-controls-pages">
|
||||
{(() => {
|
||||
/* const totalPages = totalPages; */
|
||||
if (!totalPages || totalPages === 0) return null;
|
||||
|
||||
const currentPageIndex = table.getState().pagination.pageIndex;
|
||||
const maxVisiblePages = 5;
|
||||
const visiblePagesCount = Math.min(maxVisiblePages, totalPages);
|
||||
|
||||
let startPage = currentPageIndex - Math.floor(maxVisiblePages / 2);
|
||||
startPage = Math.max(0, Math.min(startPage, totalPages - visiblePagesCount));
|
||||
|
||||
return Array.from({ length: visiblePagesCount }, (_, i) => {
|
||||
const pageIndex = startPage + i;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageIndex}
|
||||
className={currentPageIndex === pageIndex ? 'current' : 'other'}
|
||||
onClick={() => table.setPageIndex(pageIndex)}
|
||||
>
|
||||
{pageIndex + 1}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="arrow"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<IconArrowRight />
|
||||
</button>
|
||||
<button
|
||||
className="arrow"
|
||||
onClick={() => table.lastPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<IconDoubleArrowRight />
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user