add complain registration, add view complaint inforation, add status change for marches when user click on mat

This commit is contained in:
smanylov
2026-03-26 17:30:30 +07:00
parent c9f230a76f
commit 7ae0fecb61
8 changed files with 518 additions and 166 deletions
+1 -3
View File
@@ -6,7 +6,7 @@ import FilePageViolationInfo from '@/app/ui/file-page/file-page-violation-info';
import ViolationPpageFileStatistic from '@/app/ui/file-page/file-page-file-statistic'; import ViolationPpageFileStatistic from '@/app/ui/file-page/file-page-file-statistic';
import FilePageFileInfo from '@/app/ui/file-page/file-page-file-info'; import FilePageFileInfo from '@/app/ui/file-page/file-page-file-info';
import FilePageActions from '@/app/ui/file-page/file-page-actions'; import FilePageActions from '@/app/ui/file-page/file-page-actions';
import FilePageViolationsList from '@/app/ui/file-page/file-page-violations-list'; import FilePageViolationsList from '@/app/ui/file-page/violation-table/file-page-violations-list';
import FilePageNote from '@/app/ui/file-page/file-page-note'; import FilePageNote from '@/app/ui/file-page/file-page-note';
import { getFileViolations } from '@/app/actions/violationActions'; import { getFileViolations } from '@/app/actions/violationActions';
import { viewFileInfo } from '@/app/actions/fileEntity'; import { viewFileInfo } from '@/app/actions/fileEntity';
@@ -62,7 +62,6 @@ export default async function Page({
const currentPage = Number(page) || 1; const currentPage = Number(page) || 1;
try { try {
const fileViolations = await getFileViolations(id, currentPage);
const fileInfo: FileDetails = await viewFileInfo(id); const fileInfo: FileDetails = await viewFileInfo(id);
return ( return (
@@ -78,7 +77,6 @@ export default async function Page({
</div> </div>
<div> <div>
<FilePageViolationsList <FilePageViolationsList
fileViolations={fileViolations}
currentPage={currentPage} currentPage={currentPage}
fileId={id} fileId={id}
/> />
+136 -2
View File
@@ -22,10 +22,10 @@ export async function getViolationSearchStatus() {
if (text) { if (text) {
try { try {
const parsed = JSON.parse(text); const parsed = JSON.parse(text);
console.log('Parsed:', parsed);
return parsed; return parsed;
} catch (e) { } catch (e) {
console.log('Not JSON, raw text:', text);
return { return {
status: text === 'Task not found' ? 'task-not-found' : text status: text === 'Task not found' ? 'task-not-found' : text
}; };
@@ -257,4 +257,138 @@ export async function fetchViolationStatistic() {
} catch (error) { } catch (error) {
return null return null
} }
}
interface complainBody {
previousText: string,
errorMessage: string | null,
success: boolean
}
export async function createComplaint(
state: complainBody | undefined,
formData: FormData
): Promise<complainBody> {
const email = await getSessionData('email');
const violationId = formData.get('violationId') as string || '';
const text = formData.get('note') as string || '';
try {
const response = await fetch(`${API_BASE_URL}/api/v1/data`, {
method: 'POST',
body: JSON.stringify({
version: 1,
msg_id: 30013,
message_body: {
action: 'create',
violation_id: violationId,
text: text,
email: email
}
}),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
if (response.ok) {
let parsed = await response.json();
if (parsed?.message_code === 0) {
return {
errorMessage: violationId,
previousText: text,
success: true
};
} else {
throw parsed;
}
} else {
throw (`${response.status}`);
}
} catch (error) {
return {
errorMessage: violationId,
previousText: text,
success: false
}
}
}
export async function fetchComplainInfo(id: number) {
try {
const response = await fetch(`${API_BASE_URL}/api/v1/data`, {
method: 'POST',
body: JSON.stringify({
version: 1,
msg_id: 30013,
message_body: {
action: 'get_by_violation',
violation_id: id
}
}),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
if (response.ok) {
let parsed = await response.json();
if (parsed?.message_code === 0) {
return {
body: parsed?.message_body
}
} else {
throw parsed;
}
} else {
throw (`${response.status}`);
}
} catch (error) {
return {
errorMessage: 'error'
}
}
}
export async function updateMatchStatus(id: number, status: string) {
const token = await getSessionData('token');
try {
const response = await fetch(`${API_BASE_URL}/api/v1/data`, {
method: 'POST',
body: JSON.stringify({
version: 1,
msg_id: 30009,
message_body: {
action: 'updateStatus',
violation_id: id,
status: status,
token: token
}
}),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
if (response.ok) {
let parsed = await response.json();
if (parsed?.message_body.status === 'SHOWED') {
return true
} else {
throw parsed;
}
} else {
throw (`${response.status}`);
}
} catch (error) {
return false
}
} }
@@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query';
import { getFileViolations } from '@/app/actions/violationActions';
export interface ViolationFileDetail {
id: number;
url: string;
page_url: string;
page_title: string;
host: string;
status: string;
created_date: string;
file_id: string;
}
interface ViolationFile {
violations: ViolationFileDetail[],
total_elements: number,
total_pages: number,
current_page: number,
page_size: number,
has_next: boolean,
has_previous: boolean
}
export const useFileViolations = (id: string, page: number) => {
return useQuery({
queryKey: ['fileViolations', id, page],
queryFn: () => {
return getFileViolations(id, page)
},
select: (data: ViolationFile | null) => {
if (!data) {
return null
}
return data;
},
retry: false
});
};
+87 -62
View File
@@ -4361,56 +4361,6 @@
} }
} }
.action-buttons {
display: flex;
gap: 10px;
flex-direction: column;
/* flex-wrap: wrap; */
.btn-action {
flex: 1;
/* min-width: 150px; */
width: 100%;
padding: 12px 20px;
border-radius: 12px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
}
.btn-warning {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(245, 158, 11, 0.4);
}
}
.btn-success {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(16, 185, 129, 0.4);
}
}
.btn-secondary {
background: linear-gradient(135deg, #64748b, #475569);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(100, 116, 139, 0.4);
}
}
}
.btn-small { .btn-small {
padding: 6px 12px; padding: 6px 12px;
border-radius: 8px; border-radius: 8px;
@@ -4421,6 +4371,10 @@
transition: all 0.3s ease; transition: all 0.3s ease;
text-decoration: none; text-decoration: none;
display: inline-block; display: inline-block;
&:disabled {
opacity: 0.6;
}
} }
.btn-primary-small { .btn-primary-small {
@@ -4547,22 +4501,21 @@
} }
.violation-info { .violation-info {
/* background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border: 1px solid #e2e8f0;
border-radius: 12px;
*/
background: #fff; background: #fff;
border: 1px solid #f3f4f6; border: 1px solid #f3f4f6;
border-radius: 20px; border-radius: 20px;
box-shadow: 0 4px 20px #0000001a; box-shadow: 0 4px 20px #0000001a;
padding: 15px; padding: 15px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
min-height: 500px;
&-content { &-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
min-height: 0;
} }
&-grid { &-grid {
@@ -4583,22 +4536,19 @@
} }
&-image { &-image {
margin-bottom: 20px; margin-bottom: 20px;
/* padding-bottom: 15px; */ width: 300px;
/* border-bottom: 2px solid #e6e8eb; */ height: 200px;
img { img {
max-width: 300px;
width: 100%; width: 100%;
height: 100%;
object-fit: contain;
} }
} }
&-source { &-source {
margin-bottom: 20px; margin-bottom: 20px;
/* padding-bottom: 15px; */
/* border-bottom: 2px solid #e6e8eb; */
span { span {
color: #64748b; color: #64748b;
@@ -4614,6 +4564,81 @@
border-left: 1px solid #e2e8f0; border-left: 1px solid #e2e8f0;
padding-left: 15px; padding-left: 15px;
} }
&-header {
display: grid;
grid-template-columns: 300px auto;
gap: 10px;
flex-shrink: 0;
border-bottom: 1px solid rgb(226, 232, 240);
}
&-case-grid {
display: grid;
grid-template-rows: 1fr 1fr;
gap: 5px;
flex: 1;
min-height: 0;
overflow: auto;
}
&-case {
padding: 10px;
&:first-child {
border-bottom: 1px solid rgb(226, 232, 240);
padding-bottom: 10px;
}
.btn-action {
flex: 1;
width: 100%;
padding: 12px 20px;
border-radius: 12px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
max-width: 300px;
}
.btn-case {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
margin-bottom: 20px;
&:hover {
transform: translateY(-2px);
}
}
/* .btn-warning {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
}
.btn-success {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(16, 185, 129, 0.4);
}
}
.btn-secondary {
background: linear-gradient(135deg, #64748b, #475569);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(100, 116, 139, 0.4);
}
} */
}
} }
.pagination { .pagination {
@@ -0,0 +1,137 @@
'use client'
import { ViolationFileDetail } from '@/app/hooks/react-query/useFileViolations';
import { useActionState, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { createComplaint, fetchComplainInfo } from '@/app/actions/violationActions';
import { toast } from 'sonner';
import { useQuery, useQueryClient } from '@tanstack/react-query';
interface complaintInfo {
id: number,
status: string,
complaint_text: string,
violation_id: number,
email: string,
created_at: string,
updated_at: string,
not_moderated: boolean
}
export default function CaseComplaint({ selectedViolation }: { selectedViolation: ViolationFileDetail }) {
const [showCreateCase, setShowCreateCase] = useState(false);
const t = useTranslations('Global');
const [state, formAction, isPending] = useActionState(createComplaint, undefined);
const queryClient = useQueryClient();
const { data: complainInfo, isLoading: isLoadingComplain } = useQuery({
queryKey: ['complainInfo', selectedViolation.id],
queryFn: () => fetchComplainInfo(selectedViolation.id),
select: (data) => {
if (data) {
return data
}
},
enabled: selectedViolation.status !== 'NEW' &&
selectedViolation.status !== 'CREATED' &&
selectedViolation.status !== 'SHOWED',
});
useEffect(() => {
if (state?.success) {
toast.success('Жалоба зарегистрирована');
queryClient.invalidateQueries({ queryKey: ['fileViolations'] });
queryClient.invalidateQueries({ queryKey: ['complainInfo', selectedViolation.id] });
} else if (state && !state.success) {
toast.warning('Жалоба не зарегистрирована');
}
}, [state, queryClient, selectedViolation.id]);
function toggleForm() {
setShowCreateCase(!showCreateCase);
}
if (selectedViolation.status === 'NEW' || selectedViolation.status === 'CREATED' || selectedViolation.status === 'SHOWED') {
return (
<div className="violation-info-case">
{!showCreateCase ? (
<button
type="button"
className="btn-action btn-case"
onClick={toggleForm}
>
Подача жалобы
</button>
) : (
<div>
<button
type="button"
className="btn-action btn-case"
onClick={toggleForm}
>
{t('cancel')}
</button>
<div className="note-form">
<form action={formAction}>
<input type="hidden" name="violationId" value={selectedViolation.id} />
<textarea
name="note"
className="note-textarea"
placeholder="Текст нарушения...">
</textarea>
<button
type="submit"
className="btn-small btn-primary-small"
disabled={isPending}
>
Отправить нарушение
</button>
</form>
</div>
</div>
)}
</div>
);
} else {
if (isLoadingComplain) {
return (
<div className="violation-info-case">
<div>Загрузка...</div>
</div>
);
}
return (
<div className="violation-info-case">
{complainInfo?.body.content ? (
<div>
{complainInfo.body.content.map((item: complaintInfo) => {
return (
<div key={item.id}>
<h3
className="mb-3"
>
Информация о жалобе
</h3>
<div className="complaint-details">
<p><strong>ID:</strong> {item.id}</p>
<p><strong>Статус:</strong> {item.status}</p>
<p><strong>Текст жалобы:</strong> {item.complaint_text}</p>
<p><strong>ID нарушения:</strong> {item.violation_id}</p>
<p><strong>Email:</strong> {item.email}</p>
<p><strong>Дата создания:</strong> {item.created_at}</p>
<p><strong>Дата обновления:</strong> {item.updated_at}</p>
</div>
</div>
);
})}
</div>
) : (
<div>Нет информации о жалобе</div>
)}
</div>
);
}
}
@@ -0,0 +1,14 @@
import { ViolationFileDetail } from '@/app/hooks/react-query/useFileViolations';
export default function CaseViolation({ selectedViolation }: { selectedViolation: ViolationFileDetail }) {
return (
<div
className="violation-info-case"
>
<button type="button" className="btn-action btn-case">
Создание дела
</button>
</div>
)
}
@@ -0,0 +1,98 @@
'use client'
import Link from 'next/link';
import { formatDate } from '@/app/lib/formatDate';
import { useTranslations } from 'next-intl';
import { ViolationFileDetail } from '@/app/hooks/react-query/useFileViolations';
import CaseComplaint from '@/app/ui/file-page/violation-table/case-complaint';
import CaseViolation from '@/app/ui/file-page/violation-table/case-violation';
import { updateMatchStatus } from '@/app/actions/violationActions';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
export default function FilePageViolationInfo({ selectedViolation }: { selectedViolation: ViolationFileDetail | null }) {
const t = useTranslations('Global');
const queryClient = useQueryClient();
useEffect(() => {
const updateStatus = async () => {
if (selectedViolation && (selectedViolation.status === 'CREATED' || selectedViolation.status === 'NEW')) {
const response = await updateMatchStatus(selectedViolation.id, 'SHOWED');
if (response) {
queryClient.invalidateQueries({ queryKey: ['fileViolations'] });
}
}
};
updateStatus();
}, [selectedViolation, queryClient]);
if (!selectedViolation) {
return (
<div>
not found
</div>
)
}
return (
<div
className="violation-info"
>
<div
className="violation-info-content"
>
<h3
className="violation-info-title"
>
{selectedViolation?.page_title}
</h3>
<div
className="violation-info-header"
>
<div
className="violation-info-image"
>
<img src={selectedViolation?.url} alt={selectedViolation?.page_title} />
</div>
<div>
<div
className="violation-info-source"
>
<span>
{t('source')}:
</span>
<Link
href={selectedViolation?.page_url ? selectedViolation?.page_url : '#'}
className="source"
target="_blank"
scroll={false}
>
{selectedViolation?.page_url}
</Link>
</div>
<div
className="violation-info-source"
>
<span>
{t('date')} {selectedViolation?.created_date ? formatDate(selectedViolation?.created_date) : '#'}
</span>
</div>
<div>
{selectedViolation?.status}
</div>
</div>
</div>
<div
className="violation-info-case-grid"
>
<CaseComplaint selectedViolation={selectedViolation} />
<CaseViolation selectedViolation={selectedViolation} />
</div>
</div>
</div>
)
}
@@ -4,37 +4,15 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { formatDate } from '@/app/lib/formatDate'; import FilePageViolationInfo from '@/app/ui/file-page/violation-table/file-page-violation-info';
import ViolationPageNote from '@/app/ui/file-page/file-page-note'; import { useFileViolations } from '@/app/hooks/react-query/useFileViolations';
import { ViolationFileDetail } from '@/app/hooks/react-query/useFileViolations';
interface ViolationFileDetail {
id: number;
url: string;
page_url: string;
page_title: string;
host: string;
status: string;
created_date: string;
file_id: string;
}
interface ViolationFile {
violations: ViolationFileDetail[],
total_elements: number,
total_pages: number,
current_page: number,
page_size: number,
has_next: boolean,
has_previous: boolean
}
export default function FilePageViolationsList({ export default function FilePageViolationsList({
fileViolations,
currentPage, currentPage,
fileId fileId
}: { }: {
fileViolations: ViolationFile,
currentPage: number, currentPage: number,
fileId: string fileId: string
}) { }) {
@@ -42,6 +20,7 @@ export default function FilePageViolationsList({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [selectedViolation, setSelectedViolation] = useState<ViolationFileDetail | null>(null); const [selectedViolation, setSelectedViolation] = useState<ViolationFileDetail | null>(null);
const { data: fileViolations } = useFileViolations(fileId, currentPage);
if (!fileViolations?.violations) { if (!fileViolations?.violations) {
return null; return null;
@@ -147,80 +126,8 @@ export default function FilePageViolationsList({
})} })}
</div> </div>
<div <FilePageViolationInfo selectedViolation={selectedViolation} />
className="violation-info"
>
<div
className="violation-info-grid"
>
<div
className="violation-info-content"
>
<h3
className="violation-info-title"
>
{selectedViolation?.page_title}
</h3>
<div
className="violation-info-image"
>
<img src={selectedViolation?.url} alt={selectedViolation?.page_title} />
</div>
<div
className="violation-info-source"
>
<span>
{t('source')}:
</span>
<Link
href={selectedViolation?.page_url ? selectedViolation?.page_url : '#'}
className="source"
target="_blank"
scroll={false}
>
{selectedViolation?.page_url}
</Link>
</div>
<div
className="violation-info-source"
>
<span>
{t('date')} {selectedViolation?.created_date ? formatDate(selectedViolation?.created_date) : '#'}
</span>
</div>
<div>
{selectedViolation?.status}
</div>
<div
className="mt-auto"
>
Тут думаю нужно будет отображать инфу о работе с нарушением и её статус
</div>
</div>
<div
className="violation-info-actions"
>
<div className="action-buttons">
<button type="button" className="btn-action btn-warning">
Взять в работу
</button>
<button type="button" className="btn-action btn-success">
Отметить решенным
</button>
<button type="button" className="btn-action btn-secondary">
Ложное срабатывание
</button>
</div>
</div>
</div>
{/* <ViolationPageNote /> */}
</div>
</div> </div>
{fileViolations.total_pages > 1 && ( {fileViolations.total_pages > 1 && (
<div className="pagination"> <div className="pagination">