diff --git a/package.json b/package.json index 03782a1..983f2de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "no-copy-admin-panel-frontend", - "version": "0.12.0", + "version": "0.13.0", "private": true, "scripts": { "dev": "next dev -p 2996", diff --git a/src/app/[locale]/pages/content/page.tsx b/src/app/[locale]/pages/content/page.tsx index 4a2a853..9e3b5a8 100644 --- a/src/app/[locale]/pages/content/page.tsx +++ b/src/app/[locale]/pages/content/page.tsx @@ -1,4 +1,5 @@ import ContentModerationTable from '@/app/ui/content/content-moderation-table'; + export default function Page() { return (
diff --git a/src/app/actions/contentActions.ts b/src/app/actions/contentActions.ts index 6afbbde..55604cc 100644 --- a/src/app/actions/contentActions.ts +++ b/src/app/actions/contentActions.ts @@ -3,23 +3,34 @@ import { getSessionData } from '@/app/actions/session'; import { API_BASE_URL } from '@/app/actions/definitions'; -export async function fetchModerationContentList(page?: number, size?: number, sortBy?: string, sortDirection?: 'asc' | 'desc' | string, nameQuery?: string) { +export async function fetchModerationContentList(page?: number, size?: number, sortBy?: string, sortDirection?: 'asc' | 'desc' | string) { const token = await getSessionData('token'); console.log('fetchModerationContentList'); +/* console.log( + { + action: 'files_for_moderation', + page: page, + page_size: size, + sort_by: sortBy || '', + sort_direction: sortDirection || 'asc', + } + ) */ try { - const response = await fetch(`${API_BASE_URL}/api/admin`, { + const response = await fetch(`${API_BASE_URL}/api/admin/info`, { method: 'POST', body: JSON.stringify({ version: 1, - msg_id: 20005, + msg_id: 40001, message_body: { action: 'files_for_moderation', -/* page: page, - size: size, - sort_by: sortBy || '', - sort_direction: sortDirection || 'asc', - full_name: nameQuery */ + page: page, + page_size: size, +/* sort_by: sortBy || '', + sort_order: sortDirection || 'asc', */ + /* full_name: nameQuery, */ + /* fileName: '', + userId: '' */ } }), headers: { @@ -34,7 +45,48 @@ export async function fetchModerationContentList(page?: number, size?: number, s console.log(parsed); if (parsed.message_code === 0) { - return parsed.message_body; + return parsed.message_body.message_body; + } else { + return null; + } + + } else { + throw new Error(`${response.status}`); + } + } catch (error) { + return null; + } +} + +export async function changeModerationContentStatus(fileId?: string, fileStatus?: string) { + const token = await getSessionData('token'); + console.log(`changeModerationContentStatus - ${fileId} - ${fileStatus}`); + + try { + const response = await fetch(`${API_BASE_URL}/api/admin/info`, { + method: 'POST', + body: JSON.stringify({ + version: 1, + msg_id: 40002, + message_body: { + action: 'change_file_status', + file_id: fileId, + file_status: fileStatus + } + }), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const parsed = await response.json(); + console.log(parsed); + + if (parsed.message_code === 0) { + return parsed.message_body.message_body; } else { return null; } diff --git a/src/app/hooks/react-query/useContentForModeration.ts b/src/app/hooks/react-query/useContentForModeration.ts index a59c84e..d4d5077 100644 --- a/src/app/hooks/react-query/useContentForModeration.ts +++ b/src/app/hooks/react-query/useContentForModeration.ts @@ -1,22 +1,43 @@ import { useQuery } from '@tanstack/react-query'; -import {fetchModerationContentList} from '@/app/actions/contentActions'; - -export interface ContentForModeration { +import { fetchModerationContentList } from '@/app/actions/contentActions'; +export interface ModerationFile { + fileName: string; + moderationInfo: { + appealInfo: null | any; + hasActiveAppeal: boolean; + }; + userId: number; + fileId: string; + status: 'BLOCKED' | 'MODERATION' | string; } -export const useContentForModeration = (page?: number, size?: number, sortBy?: string, sortDirection?: 'asc' | 'desc' | string, nameQuery?: string) => { +export interface ContentForModeration { + files: ModerationFile[]; + totalPages: number; + currentPage: number; + pageSize: number; + totalCount: number; +} + +export const useContentForModeration = ( + page?: number, + size?: number, + sortBy?: string, + sortDirection?: 'asc' | 'desc' | string +) => { return useQuery({ - queryKey: ['contentForModeration'], + queryKey: ['contentForModeration', page, size, sortBy, sortDirection], queryFn: () => { - return fetchModerationContentList(page, size, sortBy, sortDirection, nameQuery) + return fetchModerationContentList(page, size, sortBy, sortDirection); }, - select: (data: any[] | null) => { + select: (data: ContentForModeration | null) => { if (!data) { - return null + return null; } - return data; + return data; // теперь возвращаем весь объект с пагинацией }, - retry: false + refetchInterval: 30000, + placeholderData: (previousData) => previousData // для оптимистичных обновлений }); }; \ No newline at end of file diff --git a/src/app/styles/file-moderation-modal.scss b/src/app/styles/file-moderation-modal.scss new file mode 100644 index 0000000..2e91676 --- /dev/null +++ b/src/app/styles/file-moderation-modal.scss @@ -0,0 +1,127 @@ +@use './variable.scss' as v; + +.file-moderation-modal { + padding: 1.5rem; + min-width: 400px; + + &__title { + font-size: 1.5rem; + font-weight: bold; + margin-bottom: 1rem; + } + + &__content { + display: flex; + flex-direction: column; + gap: 1rem; + } + + &__info-section, + &__status-section, + &__status-change-section { + border-bottom: 1px solid #e5e7eb; + padding-bottom: 0.75rem; + } + + &__label, + &__status-label, + &__status-change-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #374151; + } + + &__label { + margin-bottom: 0.25rem; + } + + &__status-label { + margin-bottom: 0.25rem; + } + + &__status-change-label { + margin-bottom: 0.5rem; + } + + &__value { + color: #111827; + word-break: break-word; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding-top: 1rem; + } + + &__button { + padding: 0.5rem 1rem; + border-radius: 0.25rem; + transition: all 0.2s; + cursor: pointer; + border: none; + font-size: 0.875rem; + + &:disabled { + cursor: not-allowed; + } + } + + &__button-cancel { + @extend .file-moderation-modal__button; + color: #374151; + background-color: #f3f4f6; + + &:hover:not(:disabled) { + background-color: #e5e7eb; + } + } + + &__button-save { + @extend .file-moderation-modal__button; + color: white; + background-color: #3b82f6; + + &:hover:not(:disabled) { + background-color: #2563eb; + } + + &:disabled { + background-color: #93c5fd; + } + } +} + +.status-badge { + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + display: inline-block; + + &--blocked { + @extend .status-badge; + background-color: #fee2e2; + color: #991b1b; + } + + &--moderation { + @extend .status-badge; + background-color: #fef3c7; + color: #92400e; + } + + &--approved { + @extend .status-badge; + background-color: #dcfce7; + color: #166534; + } + + &--default { + @extend .status-badge; + background-color: #f3f4f6; + color: #374151; + } +} \ No newline at end of file diff --git a/src/app/styles/global-styles.scss b/src/app/styles/global-styles.scss index 4c4913d..6e8f9e1 100644 --- a/src/app/styles/global-styles.scss +++ b/src/app/styles/global-styles.scss @@ -1,6 +1,7 @@ @use './variable.scss' as v; @use './edit-permissions-modal.scss'; @use './animation.scss'; +@use './file-moderation-modal.scss'; :root { --primary-color: #2563eb; diff --git a/src/app/ui/content/FileModerationModal.tsx b/src/app/ui/content/FileModerationModal.tsx new file mode 100644 index 0000000..d03f16a --- /dev/null +++ b/src/app/ui/content/FileModerationModal.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import DropDownList from '@/app/components/dropDownList'; +import { changeModerationContentStatus } from '@/app/actions/contentActions'; +import { ModerationFile } from '@/app/hooks/react-query/useContentForModeration'; + +interface FileModerationModalProps { + file: ModerationFile | null; + onClose: () => void; +} + +const StatusBadge = ({ status }: { status: string }) => { + const getBadgeClass = () => { + switch (status) { + case 'BLOCKED': + return 'status-badge--blocked'; + case 'MODERATION': + return 'status-badge--moderation'; + case 'APPROVED': + return 'status-badge--approved'; + default: + return 'status-badge--default'; + } + }; + + const getLabel = () => { + switch (status) { + case 'BLOCKED': + return 'Заблокировано'; + case 'MODERATION': + return 'На модерации'; + case 'APPROVED': + return 'Одобрено'; + default: + return status; + } + }; + + return ( + + {getLabel()} + + ); +}; + +export default function FileModerationModal({ file, onClose }: FileModerationModalProps) { + const [selectedStatus, setSelectedStatus] = useState(file?.status || 'MODERATION'); + const queryClient = useQueryClient(); + + const changeStatusMutation = useMutation({ + mutationFn: async ({ fileId, fileStatus }: { fileId: string; fileStatus: string }) => { + return await changeModerationContentStatus(fileId, fileStatus); + }, + onSuccess: (data) => { + if (data) { + toast.success('Статус файла успешно изменен'); + queryClient.invalidateQueries({ queryKey: ['contentForModeration'] }); + onClose(); + } else { + toast.error('Не удалось изменить статус файла'); + } + }, + onError: () => { + toast.error('Произошла ошибка при изменении статуса'); + }, + }); + + const handleStatusChange = () => { + if (!file?.fileId) { + toast.error('ID файла не найден'); + return; + } + + changeStatusMutation.mutate({ + fileId: file.fileId, + fileStatus: selectedStatus, + }); + }; + + if (!file) return null; + + return ( +
+

Управление файлом

+ +
+ {/* Информация о файле */} +
+ +

{file.fileName}

+
+ +
+ +

{file.userId}

+
+ +
+ + +
+ + {/* Смена статуса */} +
+ + setSelectedStatus(value)} + > +
  • На модерации
  • +
  • Заблокировано
  • +
    +
    + + {/* Кнопки действий */} +
    + + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/app/ui/content/content-moderation-table.tsx b/src/app/ui/content/content-moderation-table.tsx index 0c2bc0e..d448359 100644 --- a/src/app/ui/content/content-moderation-table.tsx +++ b/src/app/ui/content/content-moderation-table.tsx @@ -12,47 +12,27 @@ import { ColumnFiltersState, } from '@tanstack/react-table'; import { IconEye, IconDoubleArrowRight, IconArrowRight, IconDoubleArrowLeft, IconArrowLeft, IconArrowUp, IconArrowDown, IconFilter } from '@/app/ui/icons/icons'; -import { useTranslations, useLocale } from 'next-intl'; +import { useTranslations } 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 { fetchUsesInfo } from '@/app/actions/usersActions'; -import { UserInfoModalWindow } from '@/app/ui/users/user-info-modal-window'; -import { useContentForModeration, ContentForModeration } from '@/app/hooks/react-query/useContentForModeration'; +import { useContentForModeration, ModerationFile, ContentForModeration } from '@/app/hooks/react-query/useContentForModeration'; +import FileModerationModal from './FileModerationModal'; -// Форматирование даты из timestamp -const formatDate = (timestamp: number) => { - const date = new Date(timestamp); - 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 = (timestamp: number) => { - const date = new Date(timestamp); - const hours = date.getHours().toString().padStart(2, '0'); - const minutes = date.getMinutes().toString().padStart(2, '0'); - - return `${hours}:${minutes}`; -}; - -const cutFileName = (userId: string) => { - const MAX_FILE_NAME_LENGTH = 26; - const lastDotIndex = userId.lastIndexOf('.'); +const cutFileName = (fileName: string) => { + const MAX_FILE_NAME_LENGTH = 30; + const lastDotIndex = fileName.lastIndexOf('.'); if (lastDotIndex <= 0) { - return userId.length > MAX_FILE_NAME_LENGTH ? userId.substring(0, MAX_FILE_NAME_LENGTH - 3) + '...' : userId; + return fileName.length > MAX_FILE_NAME_LENGTH ? fileName.substring(0, MAX_FILE_NAME_LENGTH - 3) + '...' : fileName; } - const nameWithoutExtension = userId.substring(0, lastDotIndex); - const extension = userId.substring(lastDotIndex); + const nameWithoutExtension = fileName.substring(0, lastDotIndex); + const extension = fileName.substring(lastDotIndex); - if (userId.length <= MAX_FILE_NAME_LENGTH) { - return userId; + if (fileName.length <= MAX_FILE_NAME_LENGTH) { + return fileName; } const maxNameLength = MAX_FILE_NAME_LENGTH - extension.length - 3; @@ -64,52 +44,49 @@ const cutFileName = (userId: string) => { return nameWithoutExtension.substring(0, maxNameLength) + '...' + extension; } +const StatusBadge = ({ status }: { status: string }) => { + const statusConfig: Record = { + 'BLOCKED': { className: 'bg-red-100 text-red-800', label: 'Заблокировано' }, + 'MODERATION': { className: 'bg-yellow-100 text-yellow-800', label: 'На модерации' }, + 'APPROVED': { className: 'bg-green-100 text-green-800', label: 'Одобрено' }, + }; + + const config = statusConfig[status] || { className: 'bg-gray-100 text-gray-800', label: status }; + + return ( + + {config.label} + + ); +}; + export default function ContentModerationTable({ permission }: { permission: number }) { // Состояния const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10, }); - const [searchInputValue, setSearchInputValue] = useState(''); const [openWindow, setOpenWindow] = useState(false); const [openWindowChildren, setOpenWindowChildren] = useState(null); - const debouncedSetSearchQuery = useDebouncedCallback( - (value: string) => { - setSearchQuery(value); - setPagination(prev => ({ ...prev, pageIndex: 0 })); - }, - 500 - ); - - const handleSearchInputChange = useCallback((e: React.ChangeEvent) => { - const value = e.target.value; - setSearchInputValue(value); - debouncedSetSearchQuery(value); - }, [debouncedSetSearchQuery]); + const [selectedFile, setSelectedFile] = useState(null); const t = useTranslations("Global"); - const locale = useLocale(); + // Получаем параметры сортировки для API const getSortParams = useMemo(() => { if (sorting.length === 0) { - return { sortBy: '', sortOrder: undefined }; + return { sortBy: '', sortDirection: undefined }; } const sort = sorting[0]; const sortByMap: Record = { - 'id': 'supportId', - 'userName': 'fullName', - 'email': 'email', - 'verificationStatus': 'verificationStatus', + 'fileName': 'fileName', + 'userId': 'userId', + 'status': 'status', 'createdAt': 'createdAt', - 'tariff': 'tariff', - 'tokens': 'tokens', - 'userCompany': 'userCompany', }; - console.log(sortByMap[sort.id]); return { sortBy: sortByMap[sort.id] || sort.id, @@ -117,273 +94,150 @@ export default function ContentModerationTable({ permission }: { permission: num }; }, [sorting]); - const { data: tableData, + const { + data: moderationData, isLoading, isError, isFetching, error - } = useContentForModeration(pagination.pageIndex, pagination.pageSize, getSortParams?.sortBy, getSortParams?.sortDirection, searchQuery); + } = useContentForModeration( + pagination.pageIndex, + pagination.pageSize, + getSortParams.sortBy, + getSortParams.sortDirection + ); useEffect(() => { - console.log(tableData); - }, [tableData]) + console.log('Moderation data:', moderationData); + }, [moderationData]); const queryClient = useQueryClient(); - // Определение колонок - const columns = useMemo[]>(() => { - const allColumns: ColumnDef[] = [ + const handleViewFile = useCallback((file: ModerationFile) => { + console.log('View file:', file); + setSelectedFile(file); + setOpenWindow(true); + }, []); + + const handleCloseModal = useCallback(() => { + setOpenWindow(false); + setSelectedFile(null); + }, []); + + const columns = useMemo[]>(() => { + const allColumns: ColumnDef[] = [ { - accessorKey: 'id', + accessorKey: 'fileName', header: ({ column }) => (
    - ID + Название файла
    ), cell: ({ row }) => ( -
    -
    -
    - {'row.original.id'} -
    +
    +
    + {cutFileName(row.original.fileName)}
    ), - enableColumnFilter: false, + enableColumnFilter: true, }, { - accessorKey: 'userName', + accessorKey: 'userId', header: ({ column }) => (
    - Пользователь + User ID -
    - ), - cell: ({ row }) => { - return ( -
    - {'row.original.userName'} -
    - ) - }, - }, - { - accessorKey: 'userEmail', - header: ({ column }) => ( -
    - - email - - -
    - ), - cell: ({ row }) => { - return ( -
    - {'row.original.userEmail'} -
    - ) - }, - }, - { - accessorKey: 'verificationStatus', - header: ({ column }) => ( -
    - - статус KYC - -
    ), cell: ({ row }) => (
    - {'row.original.verificationStatus'} + {row.original.userId}
    ), }, { - accessorKey: 'tariff', + accessorKey: 'status', header: ({ column }) => (
    - Подписка + Статус
    ), - cell: ({ row }) => { - return ( -
    - {'row.original.tariff'} -
    - ) - }, - enableColumnFilter: false, + cell: ({ row }) => ( +
    + +
    + ), }, { - accessorKey: 'tokens', + accessorKey: 'moderationInfo.hasActiveAppeal', header: ({ column }) => (
    - Токены + Апелляция
    ), - cell: ({ row }) => { - return ( -
    - {'row.original.tokens'} -
    - ) - }, - }, { - accessorKey: 'userSubscription', - header: ({ column }) => ( -
    - - Дата регистрации - - + cell: ({ row }) => ( +
    + {row.original.moderationInfo.hasActiveAppeal ? ( + Активна + ) : ( + Нет + )}
    ), - cell: ({ row }) => { - return ( -
    - <> - {formatDate(0)} -
    - {formatDateTime(0)} - -
    - ) - }, } - ] + ]; if (permission === 3) { allColumns.push({ @@ -393,8 +247,9 @@ export default function ContentModerationTable({ permission }: { permission: num
    @@ -407,37 +262,18 @@ export default function ContentModerationTable({ permission }: { permission: num } return allColumns; - }, [permission]); + }, [permission, handleViewFile, t]); - // Фильтрация по типу файла и дате + // Фильтрация данных (клиентская фильтрация, если нужно) const filteredData = useMemo(() => { - /* let result = tableData?.users; */ - let result = []; - return [] - if (!result) { + if (!moderationData?.files) { return []; } - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase().trim(); - result = result.filter((item: any) => { - // Ищем по всем строковым полям - return Object.keys(item).some(key => { - const value = item[key]; - if (typeof value === 'string') { - return value.toLowerCase().includes(query); - } - // Если нужно искать по числам или другим типам - if (typeof value === 'number' || typeof value === 'boolean') { - return value.toString().toLowerCase().includes(query); - } - return false; - }); - }); - } + let result = [...moderationData.files]; return result; - }, [tableData, searchQuery]); + }, [moderationData]); useEffect(() => { const currentPageRows = table.getRowModel().rows; @@ -460,8 +296,7 @@ export default function ContentModerationTable({ permission }: { permission: num manualPagination: true, manualSorting: true, manualFiltering: true, - /* pageCount: tableData?.pagination?.totalPages, */ - pageCount: 0, + pageCount: moderationData?.totalPages || 0, onPaginationChange: setPagination, onSortingChange: (updater) => { const newSorting = typeof updater === 'function' ? updater(sorting) : updater; @@ -473,31 +308,28 @@ export default function ContentModerationTable({ permission }: { permission: num getPaginationRowModel: getPaginationRowModel() }); + if (isError) { + return ( +
    + Ошибка загрузки данных: {error?.message || 'Неизвестная ошибка'} +
    + ); + } + return (
    - - {openWindowChildren} + + + {/* Фильтры */}
    -
    -
    -
    {t('search')}:
    - -
    -
    {t('items-per-page')}:
    {[5, 10, 20, 50, 100].map(pageSize => ( @@ -516,9 +348,7 @@ export default function ContentModerationTable({ permission }: { permission: num {table.getHeaderGroups().map(headerGroup => ( {headerGroup.headers.map(header => ( - + {header.isPlaceholder ? null : typeof header.column.columnDef.header === 'function' @@ -530,9 +360,15 @@ export default function ContentModerationTable({ permission }: { permission: num ))} - {table.getRowModel().rows.length > 0 ? ( + {isLoading ? ( + + + Загрузка... + + + ) : table.getRowModel().rows.length > 0 ? ( table.getRowModel().rows.map(row => ( - + {row.getVisibleCells().map(cell => ( {typeof cell.column.columnDef.cell === 'function' @@ -544,7 +380,7 @@ export default function ContentModerationTable({ permission }: { permission: num )) ) : ( - + {t('no-data-for-selected-filters')} @@ -554,16 +390,16 @@ export default function ContentModerationTable({ permission }: { permission: num
    {/* Пагинация */} -{/*
    +
    {t('page')}{' '} - {(tableData?.pagination?.currentPage ?? 0) + 1} {t('out-of')} {tableData?.pagination?.totalPages ?? 1} + {(moderationData?.currentPage ?? 0) + 1} {t('out-of')} {moderationData?.totalPages ?? 1} - | {t('shown')} {table.getRowModel().rows.length} {t('out-of')} {tableData?.pagination?.totalElements ?? 0} + | {t('shown')} {table.getRowModel().rows.length} {t('out-of')} {moderationData?.totalCount ?? 0}
    @@ -585,7 +421,7 @@ export default function ContentModerationTable({ permission }: { permission: num
    {(() => { - const totalPages = tableData?.pagination?.totalPages; + const totalPages = moderationData?.totalPages; if (!totalPages || totalPages === 0) return null; const currentPageIndex = table.getState().pagination.pageIndex; @@ -626,7 +462,7 @@ export default function ContentModerationTable({ permission }: { permission: num
    -
    */} +
    ); } \ No newline at end of file