matches: add animation, add suspense for table, add no-image image

This commit is contained in:
smanylov
2026-04-08 13:41:56 +07:00
parent eb2615c303
commit 0c3ccb2806
10 changed files with 267 additions and 26 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+2 -2
View File
@@ -96,11 +96,11 @@ export const createCaseSchema = z
.object({ .object({
textArea: z textArea: z
.string() .string()
.min(6, { error: 'text-area-error-fill-complaint-text' }) .min(6, { error: 'fill-text-of-the-case' })
.trim(), .trim(),
caseTitle: z caseTitle: z
.string() .string()
.min(6, { error: 'text-area-error-fill-complaint-text' }) .min(6, { error: 'fill-title-of-the-case' })
.trim(), .trim(),
}) })
+19 -2
View File
@@ -4457,9 +4457,10 @@
padding: 15px; padding: 15px;
height: calc(16vh - 12px); height: calc(16vh - 12px);
cursor: pointer; cursor: pointer;
display: flex;
justify-content: space-between; justify-content: space-between;
opacity: 0.75; opacity: 0.75;
display: flex;
gap: 10px;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
@@ -4497,10 +4498,12 @@
max-height: 80%; max-height: 80%;
min-height: 50px; min-height: 50px;
height: 100%; height: 100%;
position: relative;
img { img {
height: 100%; height: 100% !important;
border-radius: 8px; border-radius: 8px;
width: auto !important;
} }
} }
@@ -4662,6 +4665,7 @@
&-image { &-image {
width: 300px; width: 300px;
height: 200px; height: 200px;
position: relative;
img { img {
width: 100%; width: 100%;
@@ -4727,6 +4731,7 @@
&-case { &-case {
padding: 10px; padding: 10px;
position: relative;
&:first-child { &:first-child {
border-bottom: 1px solid rgb(226, 232, 240); border-bottom: 1px solid rgb(226, 232, 240);
@@ -4763,6 +4768,12 @@
border-color: #6366f1; border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
} }
&-wrapper {
display: flex;
flex-direction: column;
margin-bottom: 10px;
}
} }
.note-form-flex { .note-form-flex {
@@ -4789,6 +4800,10 @@
border-color: #6366f1; border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
} }
&::placeholder {
font-size: 12px;
}
} }
.note-case-label { .note-case-label {
@@ -5389,6 +5404,7 @@
border-radius: 10px; border-radius: 10px;
box-shadow: 0 1px 2px #0000000d; box-shadow: 0 1px 2px #0000000d;
color: v.$p-color; color: v.$p-color;
cursor: pointer;
.icon { .icon {
width: 18px; width: 18px;
@@ -5414,6 +5430,7 @@
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 10px; border-radius: 10px;
color: v.$p-color; color: v.$p-color;
cursor: pointer;
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.5;
@@ -143,7 +143,14 @@ export default function CaseComplaint({ selectedViolation, updateStatusHandler }
if (isLoadingComplain) { if (isLoadingComplain) {
return ( return (
<div className="violation-info-case"> <div className="violation-info-case">
<div>{t('loading')}...</div> <div
className="loading-animation"
>
<div
className="global-spinner large"
>
</div>
</div>
</div> </div>
); );
} }
@@ -86,7 +86,7 @@ export default function CaseViolation({ selectedViolation, updateStatusHandler }
className="btn-action btn-case" className="btn-action btn-case"
onClick={toggleForm} onClick={toggleForm}
> >
Создание дела {t('create-case')}
</button> </button>
) : ( ) : (
<div> <div>
@@ -111,12 +111,13 @@ export default function CaseViolation({ selectedViolation, updateStatusHandler }
className="note-case-label" className="note-case-label"
htmlFor="caseTitle" htmlFor="caseTitle"
> >
Название дела {t('case-name')}
</label> </label>
<input <input
type="text" type="text"
name="caseTitle" name="caseTitle"
className="note-case-input" className="note-case-input"
placeholder={t('fill-title-of-the-case') + '...'}
/> />
{state?.errorMessage?.caseTitle && ( {state?.errorMessage?.caseTitle && (
<p <p
@@ -142,12 +143,13 @@ export default function CaseViolation({ selectedViolation, updateStatusHandler }
className="note-case-label" className="note-case-label"
htmlFor="amount" htmlFor="amount"
> >
Сумма ущерба {t('amount-of-damage')}
</label> </label>
<input <input
type="number" type="number"
name="amount" name="amount"
className="note-case-input" className="note-case-input"
placeholder={t('enter-the-amount-of-damage') + '...'}
/> />
{state?.errorMessage?.amount && ( {state?.errorMessage?.amount && (
<p <p
@@ -168,11 +170,14 @@ export default function CaseViolation({ selectedViolation, updateStatusHandler }
</section> </section>
</div> </div>
<section> <section
className="note-textarea-wrapper"
>
<textarea <textarea
name="note" name="note"
className="note-textarea" className="note-textarea"
placeholder={`Текст дела...`}> placeholder={t('fill-text-of-the-case') + '...'}
>
</textarea> </textarea>
{state?.errorMessage?.textArea && ( {state?.errorMessage?.textArea && (
<p <p
@@ -212,7 +217,15 @@ export default function CaseViolation({ selectedViolation, updateStatusHandler }
if (isLoadingCase) { if (isLoadingCase) {
return ( return (
<div className="violation-info-case"> <div className="violation-info-case">
<div>{t('loading')}...</div> <div
className="loading-animation"
>
<div
className="global-spinner large"
>
</div>
</div>
</div> </div>
); );
} }
@@ -12,6 +12,7 @@ import { useCallback, useEffect, useState } from 'react';
import { MatchStatus } from '@/app/actions/violationActions'; import { MatchStatus } from '@/app/actions/violationActions';
import { toast } from 'sonner'; import { toast } from 'sonner';
import FilePageViolationEpmtyScreen from '@/app/ui/file-page/violation-table/file-page-violation-epmty-screen'; import FilePageViolationEpmtyScreen from '@/app/ui/file-page/violation-table/file-page-violation-epmty-screen';
import Image from 'next/image';
export default function FilePageViolationInfo({ selectedViolation }: { selectedViolation: ViolationFileDetail | null }) { export default function FilePageViolationInfo({ selectedViolation }: { selectedViolation: ViolationFileDetail | null }) {
const t = useTranslations('Global'); const t = useTranslations('Global');
@@ -88,7 +89,17 @@ export default function FilePageViolationInfo({ selectedViolation }: { selectedV
<div <div
className="violation-info-image" className="violation-info-image"
> >
<img src={selectedViolation?.url} alt={selectedViolation?.page_title} /> <Image
src={selectedViolation?.url}
alt={selectedViolation?.page_title}
unoptimized
fill
sizes="100px"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = '/images/no-image.png';
}}
/>
</div> </div>
<div <div
@@ -3,12 +3,14 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import FilePageViolationInfo from '@/app/ui/file-page/violation-table/file-page-violation-info'; import FilePageViolationInfo from '@/app/ui/file-page/violation-table/file-page-violation-info';
import { useFileViolations } from '@/app/hooks/react-query/useFileViolations'; import { useFileViolations } from '@/app/hooks/react-query/useFileViolations';
import { ViolationFileDetail } from '@/app/hooks/react-query/useFileViolations'; import { ViolationFileDetail } from '@/app/hooks/react-query/useFileViolations';
import DropDownList from '@/app/components/DropDownList'; import DropDownList from '@/app/components/DropDownList';
import { IconArrowLeft, IconArrowRight, IconDoubleArrowLeft, IconDoubleArrowRight } from '@/app/ui/icons/icons'; import { IconArrowLeft, IconArrowRight, IconDoubleArrowLeft, IconDoubleArrowRight } from '@/app/ui/icons/icons';
import FilePageViolationsLoadingList from '@/app/ui/file-page/violation-table/file-page-violations-loading-list';
import Image from 'next/image';
export default function FilePageViolationsList({ export default function FilePageViolationsList({
currentPage, currentPage,
@@ -26,12 +28,36 @@ export default function FilePageViolationsList({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [selectedViolation, setSelectedViolation] = useState<ViolationFileDetail | null>(null); const [selectedViolation, setSelectedViolation] = useState<ViolationFileDetail | null>(null);
const [cachedPagination, setCachedPagination] = useState<{
total_pages: number;
total_elements: number;
current_page: number;
} | null>(null);
const { data: fileViolations, error, isPending } = useFileViolations(fileId, currentPage, status); const { data: fileViolations, error, isPending } = useFileViolations(fileId, currentPage, status);
if (!fileViolations?.violations) { useEffect(() => {
return null; if (fileViolations?.violations) {
} setCachedPagination({
total_pages: fileViolations.total_pages,
total_elements: fileViolations.total_elements,
current_page: fileViolations.current_page
});
}
}, [fileViolations]);
const paginationData = fileViolations?.violations ? fileViolations : cachedPagination;
if (!fileViolations?.violations) {
return <FilePageViolationsLoadingList
currentPage={currentPage}
fileId={fileId}
status={status}
cachedTotalPages={paginationData?.total_pages}
cachedTotalElements={paginationData?.total_elements}
cachedCurrentPage={paginationData?.current_page}
/>;
}
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
params.set('page', page.toString()); params.set('page', page.toString());
@@ -105,7 +131,7 @@ export default function FilePageViolationsList({
className="sources-list-wrapper" className="sources-list-wrapper"
> >
<div className="sources-list"> <div className="sources-list">
{fileViolations.violations.map(item => { {fileViolations?.violations.map(item => {
return ( return (
<div <div
className={`source-card ${selectedViolation?.id === item.id ? 'selected' : ''}`} className={`source-card ${selectedViolation?.id === item.id ? 'selected' : ''}`}
@@ -118,7 +144,17 @@ export default function FilePageViolationsList({
className="source-header-left" className="source-header-left"
> >
<div className="source-image"> <div className="source-image">
<img src={item.url} alt="" /> <Image
src={item.url}
alt={item.page_title}
unoptimized
fill
sizes="100px"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = '/images/no-image.png';
}}
/>
</div> </div>
<div <div
className="source-url-block" className="source-url-block"
@@ -177,9 +213,9 @@ export default function FilePageViolationsList({
className="sources-list-pagination" className="sources-list-pagination"
> >
<span> <span>
{t('total-violations')}: {fileViolations.total_elements} | {t('page')} {fileViolations.current_page} {t('out-of')} {fileViolations.total_pages} {t('total-violations')}: {fileViolations?.total_elements} | {t('page')} {fileViolations?.current_page} {t('out-of')} {fileViolations?.total_pages}
</span> </span>
{fileViolations.total_pages > 1 && ( {fileViolations?.total_pages > 1 && (
<div className="pagination-controls"> <div className="pagination-controls">
<button <button
className="arrow" className="arrow"
@@ -191,7 +227,7 @@ export default function FilePageViolationsList({
<button <button
onClick={() => handlePageChange(currentPage - 1)} onClick={() => handlePageChange(currentPage - 1)}
className="arrow" className="arrow"
disabled={!fileViolations.has_previous || isPending} disabled={!fileViolations?.has_previous || isPending}
> >
<IconArrowLeft /> <IconArrowLeft />
</button> </button>
@@ -215,14 +251,14 @@ export default function FilePageViolationsList({
<button <button
onClick={() => handlePageChange(currentPage + 1)} onClick={() => handlePageChange(currentPage + 1)}
className="arrow" className="arrow"
disabled={!fileViolations.has_next} disabled={!fileViolations?.has_next}
> >
<IconArrowRight /> <IconArrowRight />
</button> </button>
<button <button
className="arrow" className="arrow"
onClick={() => handlePageChange(fileViolations.total_pages)} onClick={() => handlePageChange(fileViolations?.total_pages)}
disabled={currentPage === fileViolations.total_pages || isPending} disabled={currentPage === fileViolations?.total_pages || isPending}
> >
<IconDoubleArrowRight /> <IconDoubleArrowRight />
</button> </button>
@@ -0,0 +1,143 @@
'use client'
import Link from 'next/link';
import FilePageViolationInfo from '@/app/ui/file-page/violation-table/file-page-violation-info';
import DropDownList from '@/app/components/DropDownList';
import { IconArrowLeft, IconArrowRight, IconDoubleArrowLeft, IconDoubleArrowRight } from '@/app/ui/icons/icons';
import { useTranslations } from 'next-intl';
export default function FilePageViolationsLoadingList({
currentPage,
fileId,
status,
cachedTotalPages,
cachedTotalElements,
cachedCurrentPage,
}: {
currentPage: number,
fileId: string,
status: string | undefined,
cachedTotalPages: number | undefined,
cachedTotalElements: number | undefined,
cachedCurrentPage: number | undefined
}) {
const t = useTranslations('Global');
const tStatus = useTranslations('Match-status');
const getPageNumbers = () => {
const pages = [];
const maxVisible = 5;
const total = cachedTotalPages;
let start = Math.max(1, currentPage - Math.floor(maxVisible / 2));
let end = Math.min(total ? total : 0, start + maxVisible - 1);
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
};
return (
<div className="block-wrapper">
<div className="card-header">
<h3 className="card-title">
{t('all-file-violations')}
</h3>
<div className="flex items-center gap-4">
<div
className="status-filter-select"
>
<DropDownList
value={status ? tStatus(status) : t('all-statuses')}
callBack={() => { }}
>
<li value=''>{t('all-statuses')}</li>
<li value="NEW">{tStatus('NEW')}</li>
<li value="SHOWED">{tStatus('SHOWED')}</li>
<li value="LEGAL_IN_WORK">{tStatus('LEGAL_IN_WORK')}</li>
<li value="COMPLAINT_IN_WORK">{tStatus('COMPLAINT_IN_WORK')}</li>
<li value="COMPLAINT_AND_LEGAL_IN_WORK">{tStatus('COMPLAINT_AND_LEGAL_IN_WORK')}</li>
<li value="AUTHORIZED_USE">{tStatus('AUTHORIZED_USE')}</li>
</DropDownList>
</div>
</div>
</div>
<div
className="sources-list-wrapper relative"
>
<div
className="loading-animation"
>
<div
className="global-spinner large"
>
</div>
</div>
</div>
<div
className="sources-list-pagination"
>
<span>
{t('total-violations')}: {cachedTotalElements} | {t('page')} {cachedCurrentPage} {t('out-of')} {cachedTotalPages}
</span>
<div className="pagination-controls">
<button
className="arrow"
onClick={() => { }}
disabled={true}
>
<IconDoubleArrowLeft />
</button>
<button
onClick={() => { }}
className="arrow"
disabled={true}
>
<IconArrowLeft />
</button>
<div
className="pagination-controls-pages"
>
{getPageNumbers().map(pageNum => (
<button
key={pageNum}
onClick={() => { }}
className={currentPage === pageNum ? 'current' : 'other'}
disabled={true}
>
{pageNum}
</button>
))}
</div>
<button
onClick={() => { }}
className="arrow"
disabled={true}
>
<IconArrowRight />
</button>
<button
className="arrow"
onClick={() => { }}
disabled={true}
>
<IconDoubleArrowRight />
</button>
</div>
</div>
</div>
)
}
+8 -1
View File
@@ -407,7 +407,14 @@
"priority": "Priority", "priority": "Priority",
"type": "Type", "type": "Type",
"lawyer": "Lawyer", "lawyer": "Lawyer",
"not-assigned": "Not assigned" "not-assigned": "Not assigned",
"create-case": "Create case",
"case-name": "Case name",
"amount-of-damage": "Amount of damage",
"text-of-the-case": "Text of the case",
"fill-text-of-the-case": "Fill the case text",
"fill-title-of-the-case": "Fill the case title",
"enter-the-amount-of-damage": "Enter the amount of damage"
}, },
"Login-register-form": { "Login-register-form": {
"and": "and", "and": "and",
+8 -1
View File
@@ -407,7 +407,14 @@
"priority": "Приоритет", "priority": "Приоритет",
"type": "Тип", "type": "Тип",
"lawyer": "Адвокат", "lawyer": "Адвокат",
"not-assigned": "Не назначено" "not-assigned": "Не назначено",
"create-case": "Создать дело",
"case-name": "Название дела",
"amount-of-damage": "Сумма ущерба",
"text-of-the-case": "Текст дела",
"fill-text-of-the-case": "Заполните текст дела",
"fill-title-of-the-case": "Заполните название дела",
"enter-the-amount-of-damage": "Введите сумму ущерба"
}, },
"Login-register-form": { "Login-register-form": {
"and": "и", "and": "и",