SI detail page cretaed
This commit is contained in:
10
src/app/[locale]/si/[id]/page.tsx
Normal file
10
src/app/[locale]/si/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import SiDetailPage from '@/widgets/detail/SiDetailPage';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiDetail({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <SiDetailPage id={Number(id)} />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { Download, CreditCard } from 'lucide-react';
|
||||
import { Download, CreditCard, Eye } from 'lucide-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useSiHistory } from '../../lib/hooks/useSiHistory';
|
||||
import { formatDate } from '@/widgets/history/lib/utils';
|
||||
@@ -9,6 +9,7 @@ import { links } from '@/shared/request/links';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { SiDocument } from '../../lib/types';
|
||||
import { SiButton } from '@/features/modals/siModal/page';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
|
||||
// ─── State badge ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,6 +90,9 @@ const SiRow: React.FC<{ item: SiDocument; index: number }> = ({
|
||||
item,
|
||||
index,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { locale } = useParams() as { locale: string };
|
||||
|
||||
const pay = useMutation({
|
||||
mutationKey: ['si-payment', item.id],
|
||||
mutationFn: () =>
|
||||
@@ -168,7 +172,13 @@ const SiRow: React.FC<{ item: SiDocument; index: number }> = ({
|
||||
{pay.isPending ? '...' : "To'lash"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-slate-300 text-xs">—</span>
|
||||
<button
|
||||
onClick={() => router.push(`/${locale}/si/${item.id}`)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 active:scale-95 transition-all duration-150"
|
||||
>
|
||||
<Eye size={11} />
|
||||
Ko'rish
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
356
src/widgets/detail/SiDetailPage.tsx
Normal file
356
src/widgets/detail/SiDetailPage.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { apiRequest } from '@/shared/request/apiRequest';
|
||||
import { links } from '@/shared/request/links';
|
||||
import { Download, CloudDownload } from 'lucide-react';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type SiResult = {
|
||||
original: number;
|
||||
ai_possible: number;
|
||||
ai: number;
|
||||
};
|
||||
|
||||
type SiDetail = {
|
||||
id: number;
|
||||
title: string;
|
||||
file: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
state: 'paid' | 'unpaid';
|
||||
total_words: number;
|
||||
si_percantage: number | null;
|
||||
result: SiResult | null;
|
||||
file_size?: number;
|
||||
file_extension?: string;
|
||||
total_price?: number | string;
|
||||
user?: { name: string; surname: string };
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('uz-UZ', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function fileExtension(url: string): string {
|
||||
const name = url.split('/').pop() ?? '';
|
||||
const ext = name.split('.').pop();
|
||||
return ext ? `.${ext}` : '—';
|
||||
}
|
||||
|
||||
function fileName(url: string): string {
|
||||
return url.split('/').pop() ?? '—';
|
||||
}
|
||||
|
||||
function fileSizeMb(bytes?: number): string {
|
||||
if (!bytes) return '—';
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4 border-b border-slate-100 last:border-0">
|
||||
<span className="text-sm font-medium text-slate-500">{label}</span>
|
||||
<span className="text-sm font-semibold text-slate-800 text-right max-w-[60%] break-all">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SiBar({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600 font-medium">{label}</span>
|
||||
<span className="font-bold text-slate-800">{value}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-700 ${color}`}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skeleton ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function Skeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse bg-slate-200 rounded-lg ${className ?? ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="h-16 bg-white border-b border-slate-200" />
|
||||
<div className="max-w-4xl mx-auto px-6 py-10 space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-6 space-y-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-6 space-y-5">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-6 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SiDetailPage({ id }: { id: number }) {
|
||||
const { locale } = useParams() as { locale: string };
|
||||
useEffect(() => {
|
||||
console.log(locale);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: doc,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ['si-detail', id],
|
||||
queryFn: (): Promise<SiDetail> =>
|
||||
apiRequest('GET', links.si_id(id)).then((res) => res.data as SiDetail),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingSkeleton />;
|
||||
|
||||
if (isError || !doc) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-slate-700 font-semibold">
|
||||
Ma'lumot topilmadi
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Ortga qaytish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Derive SI percentages
|
||||
const original = doc.result?.original ?? 100 - (doc.si_percantage ?? 0);
|
||||
const aiPossible = doc.result?.ai_possible ?? 0;
|
||||
const ai = doc.result?.ai ?? doc.si_percantage ?? 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 font-sans">
|
||||
{/* ── Header ── */}
|
||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-20">
|
||||
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="w-8 h-8 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors shrink-0"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-slate-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-base font-bold text-slate-800 truncate">
|
||||
{doc.title || 'SI tekshiruv'}
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-6 py-8 space-y-6">
|
||||
{/* ── Section 1: Asosiy ma'lumotlar ── */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* Section header */}
|
||||
<div className="bg-slate-50 border-b border-slate-100 px-6 py-4 text-center">
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-blue-600">
|
||||
Asosiy ma'lumotlar
|
||||
</h2>
|
||||
<div className="w-10 h-0.5 bg-blue-600 mx-auto mt-1.5" />
|
||||
</div>
|
||||
|
||||
<div className="px-6">
|
||||
{/* Sub-header */}
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-slate-400 py-4 border-b border-slate-100">
|
||||
Hujjat haqida ma'lumotlar
|
||||
</p>
|
||||
|
||||
<InfoRow
|
||||
label="Hujjat nomi"
|
||||
value={doc.title || fileName(doc.file)}
|
||||
/>
|
||||
{doc.user && (
|
||||
<InfoRow
|
||||
label="Tekshiruvchi"
|
||||
value={`${doc.user.name} ${doc.user.surname}`}
|
||||
/>
|
||||
)}
|
||||
<InfoRow
|
||||
label="Hujjat yuklangan vaqti"
|
||||
value={formatDate(doc.created_at)}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Hujjat faylining original nomi"
|
||||
value={fileName(doc.file)}
|
||||
/>
|
||||
<InfoRow
|
||||
label="So'zlar soni"
|
||||
value={
|
||||
doc.total_words > 0
|
||||
? doc.total_words.toLocaleString('uz-UZ')
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Hujjat fayli kenggaytmasi"
|
||||
value={fileExtension(doc.file)}
|
||||
/>
|
||||
{doc.file_size !== undefined && (
|
||||
<InfoRow
|
||||
label="Hujjat fayli o'lchami"
|
||||
value={fileSizeMb(doc.file_size)}
|
||||
/>
|
||||
)}
|
||||
{doc.total_price !== undefined && (
|
||||
<InfoRow
|
||||
label="Yechilgan summa"
|
||||
value={`${Number(doc.total_price).toLocaleString('uz-UZ')} so'm`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Download button */}
|
||||
{doc.file && (
|
||||
<div className="py-5 flex justify-end">
|
||||
<a
|
||||
href={doc.file}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl border border-cyan-500 text-cyan-600 text-sm font-semibold hover:bg-cyan-50 transition-colors"
|
||||
>
|
||||
<Download size={15} />
|
||||
Original hujjatni yuklab olish
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Section 2: SI detektor natijalari ── */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* Header row */}
|
||||
<div className="px-6 py-5 flex items-start justify-between gap-4 border-b border-slate-100">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* PDF icon */}
|
||||
<div className="w-12 h-14 bg-red-100 rounded-lg flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
className="w-7 h-7 text-red-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm5 1l5 5h-5V3z" />
|
||||
<text
|
||||
x="5"
|
||||
y="20"
|
||||
fontSize="5"
|
||||
fill="white"
|
||||
fontWeight="bold"
|
||||
>
|
||||
PDF
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-slate-800 leading-snug">
|
||||
Hujjatning SI detektori natijalari
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 mt-1.5 leading-relaxed max-w-lg">
|
||||
Ushbu oynada foydalanuvchi tomonidan yuklangan matn
|
||||
sun'iy intellekt (SI) yordamida yozilgan bo'lish
|
||||
ehtimoli bo'yicha tahlil natijalari aks etirilgan.
|
||||
Detektor matnning stilistik, grammatik va semantik
|
||||
xususiyatlarini baholab, uning qanchalik darajada sun'iy
|
||||
intellekt tomonidan generatsiya qilingan bo'lishi
|
||||
mumkinligini foizlik ko'rinishida ko'rsatadi.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doc.file && (
|
||||
<a
|
||||
href={doc.file}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-slate-800 text-white text-sm font-semibold hover:bg-slate-700 transition-colors shrink-0"
|
||||
>
|
||||
<CloudDownload size={16} />
|
||||
Yuklab olish
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bars */}
|
||||
<div className="px-6 py-6 space-y-5">
|
||||
<SiBar
|
||||
label="Original matn"
|
||||
value={original}
|
||||
color="bg-emerald-400"
|
||||
/>
|
||||
<SiBar
|
||||
label="Ehtimoliy Sun'iy Intellekt"
|
||||
value={aiPossible}
|
||||
color="bg-amber-400"
|
||||
/>
|
||||
<SiBar label="Sun'iy Intellekt" value={ai} color="bg-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,6 +140,7 @@ export function usePlagiarismForm() {
|
||||
fd.append('file', form.file!); // File object — multipart/form-data
|
||||
fd.append('certificate', String(form.certificate));
|
||||
fd.append('total_price', '41200');
|
||||
fd.append('document_type', form.document_type);
|
||||
checkdocumentRequest.mutate(fd);
|
||||
},
|
||||
[form],
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
} from './Plagiraismui';
|
||||
import { usePlagiarismForm } from '../lib/usePlagiraism';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { DOCUMENT_TYPES } from '@/features/modals/sertificateModal/types';
|
||||
import { PaymentModal } from '@/features/modals/paymentModal/ui/Paymentmodal';
|
||||
import DocumentsTypes from './documentsType';
|
||||
|
||||
const inputCls = `
|
||||
export const inputCls = `
|
||||
w-full px-3.5 py-3.5 text-[14px] text-slate-800
|
||||
bg-blue-50 border border-blue-200 rounded-xl
|
||||
placeholder:text-blue-400
|
||||
@@ -177,26 +177,11 @@ export function PlagiarismCheckForm() {
|
||||
<div className="border-t border-stone-100" />
|
||||
|
||||
{/* Document type */}
|
||||
<FieldWrapper htmlFor="document_type" label="Hujjat turi">
|
||||
<select
|
||||
id="document_type"
|
||||
value={form.document_type}
|
||||
onChange={(e) =>
|
||||
setOption(e.target.value as typeof form.document_type)
|
||||
}
|
||||
disabled={isLoading || submission.status === 'success'}
|
||||
className={`${inputCls} cursor-pointer`}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Hujjat turini tanlang...
|
||||
</option>
|
||||
{DOCUMENT_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldWrapper>
|
||||
<DocumentsTypes
|
||||
value={form.document_type}
|
||||
onChange={setOption}
|
||||
disabled={submission.status === 'success'}
|
||||
/>
|
||||
|
||||
{/* Submit */}
|
||||
<SubmitButton
|
||||
|
||||
53
src/widgets/fileUpload/ui/documentsType.tsx
Normal file
53
src/widgets/fileUpload/ui/documentsType.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { FieldWrapper } from './Plagiraismui';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiRequest } from '@/shared/request/apiRequest';
|
||||
import { links } from '@/shared/request/links';
|
||||
import { inputCls } from './Plagiraismcheckform';
|
||||
|
||||
type DocumentType = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
interface DocumentsTypesProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function DocumentsTypes({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: DocumentsTypesProps) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['document_types'],
|
||||
queryFn: (): Promise<DocumentType[]> =>
|
||||
apiRequest('GET', links.document_types).then(
|
||||
(res) => res.data as DocumentType[],
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<FieldWrapper htmlFor="document_type" label="Hujjat turi">
|
||||
<select
|
||||
id="document_type"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={isLoading || disabled}
|
||||
className={`${inputCls} cursor-pointer`}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{isLoading ? 'Yuklanmoqda...' : 'Hujjat turini tanlang...'}
|
||||
</option>
|
||||
{data?.map((type) => (
|
||||
<option key={type.id} value={String(type.id)}>
|
||||
{type.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user