mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-23 21:27:10 +00:00
feat: review
This commit is contained in:
@@ -1,31 +1,31 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
function DialogTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({
|
function DialogPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
function DialogClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({
|
||||||
@@ -37,11 +37,11 @@ function DialogOverlay({
|
|||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
@@ -55,8 +55,8 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-[600px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -67,7 +67,7 @@ function DialogContent({
|
|||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -77,7 +77,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -86,11 +86,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({
|
function DialogTitle({
|
||||||
@@ -103,7 +103,7 @@ function DialogTitle({
|
|||||||
className={cn("text-lg leading-none font-semibold", className)}
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({
|
||||||
@@ -116,7 +116,7 @@ function DialogDescription({
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -130,4 +130,4 @@ export {
|
|||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ const CompetitionsPage = () => {
|
|||||||
|
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value={CompetitionTab.ONGOING}>
|
<TabsTrigger value={CompetitionTab.ONGOING}>
|
||||||
В процессе
|
Прохожу
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value={CompetitionTab.COMPLETED}>
|
<TabsTrigger value={CompetitionTab.COMPLETED}>
|
||||||
Завершенные
|
Завершено
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
@@ -112,5 +112,4 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => {
|
|||||||
return <h1 className="w-full text-3xl font-semibold">{children}</h1>;
|
return <h1 className="w-full text-3xl font-semibold">{children}</h1>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default CompetitionsPage;
|
export default CompetitionsPage;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
import { Review, ReviewStatus } from "@/shared/types/review";
|
import { Review, ReviewStatus } from "@/shared/types/review";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
@@ -8,28 +9,52 @@ interface ReviewCardProps {
|
|||||||
export const ReviewCard = ({ review }: ReviewCardProps) => {
|
export const ReviewCard = ({ review }: ReviewCardProps) => {
|
||||||
const id = review.id.split("-").at(-1)?.slice(0, 6);
|
const id = review.id.split("-").at(-1)?.slice(0, 6);
|
||||||
|
|
||||||
|
const score = review.evaluation?.reduce((acc, e) => acc + e.mark, 0);
|
||||||
|
const maxPoints = review.criteries?.reduce((acc, c) => acc + c.max_value, 0);
|
||||||
|
|
||||||
|
const styles = review.review_status === ReviewStatus.CHECKED && {
|
||||||
|
"bg-correct text-correct-foreground": (score ?? 0) === (maxPoints ?? 0),
|
||||||
|
"bg-partial text-partial-foreground":
|
||||||
|
(score ?? 0) > 0 && (score ?? 0) < (maxPoints ?? 0),
|
||||||
|
"bg-wrong text-wrong-foreground": (score ?? 0) === 0,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card flex items-center justify-between gap-8 rounded-lg px-8 py-5">
|
<div
|
||||||
<div className="flex flex-col gap-1">
|
className={cn(
|
||||||
<p className="text-muted-foreground font-semibold">
|
"bg-card flex cursor-pointer flex-col justify-between gap-2 rounded-lg px-8 py-5 sm:flex-row sm:items-center sm:gap-8",
|
||||||
|
styles,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col gap-1 text-left">
|
||||||
|
<p className={cn("text-muted-foreground font-semibold", styles)}>
|
||||||
{review.competition_name}
|
{review.competition_name}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-2xl font-semibold">{review.task_title}</h1>
|
<h1 className="text-2xl font-semibold">{review.task_title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1 text-right">
|
<div className="flex flex-col-reverse items-end gap-1 text-right sm:flex-col">
|
||||||
<div className="text-muted-foreground flex gap-1.5 font-semibold">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap justify-end gap-1.5 font-semibold",
|
||||||
|
styles,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<p>{id}</p>
|
<p>{id}</p>
|
||||||
<p>•</p>
|
<p>•</p>
|
||||||
<p>
|
<p>
|
||||||
{review.review_status === ReviewStatus.NOT_CHECKED
|
{review.review_status === ReviewStatus.NOT_CHECKED ||
|
||||||
|
review.review_status === ReviewStatus.CHECKING
|
||||||
? `Дата посылки: ${dayjs(review.submitted_at).format("D MMMM, HH:mm")}`
|
? `Дата посылки: ${dayjs(review.submitted_at).format("D MMMM, HH:mm")}`
|
||||||
: `Дата проверки: ${review.checked_at}`}
|
: `Дата проверки: ${dayjs(review.checked_at).format("D MMMM, HH:mm")}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold">
|
<h1 className="text-2xl font-semibold">
|
||||||
{review.review_status === ReviewStatus.NOT_CHECKED
|
{review.review_status === ReviewStatus.NOT_CHECKED ||
|
||||||
|
review.review_status === ReviewStatus.CHECKING
|
||||||
? "Не проверено"
|
? "Не проверено"
|
||||||
: ""}
|
: score === 0
|
||||||
|
? "Неверный ответ"
|
||||||
|
: `Зачтено ${score}/${maxPoints}`}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Loading } from "@/components/ui/loading";
|
import { Loading } from "@/components/ui/loading";
|
||||||
import { getReviewer, getReviewerSubmissions } from "@/shared/api/review";
|
import { getReviewer, getReviewSubmissions } from "@/shared/api/review";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import { ReviewHeader } from "./modules/review-header";
|
import { ReviewHeader } from "./modules/review-header";
|
||||||
@@ -8,6 +8,8 @@ import { ReviewsList } from "./modules/reviews-list";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ReviewStatus } from "@/shared/types/review";
|
import { ReviewStatus } from "@/shared/types/review";
|
||||||
|
|
||||||
|
const TokenContext = React.createContext<string | null>(null);
|
||||||
|
|
||||||
const ReviewPage = () => {
|
const ReviewPage = () => {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -19,22 +21,28 @@ const ReviewPage = () => {
|
|||||||
});
|
});
|
||||||
const submissionsQuery = useQuery({
|
const submissionsQuery = useQuery({
|
||||||
queryKey: ["submissions", token],
|
queryKey: ["submissions", token],
|
||||||
queryFn: async () => getReviewerSubmissions(token || ""),
|
queryFn: async () => getReviewSubmissions(token || ""),
|
||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const availableReviews = React.useMemo(
|
const availableReviews = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
(submissionsQuery.data?.submissions || []).filter(
|
(submissionsQuery.data?.submissions || []).filter(
|
||||||
(s) => s.review_status === ReviewStatus.NOT_CHECKED,
|
(s) =>
|
||||||
|
s.review_status === ReviewStatus.NOT_CHECKED ||
|
||||||
|
s.review_status === ReviewStatus.CHECKING,
|
||||||
),
|
),
|
||||||
[submissionsQuery.data],
|
[submissionsQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkedReviews = React.useMemo(
|
const checkedReviews = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
(submissionsQuery.data?.submissions || []).filter(
|
(submissionsQuery.data?.submissions || [])
|
||||||
(s) => s.review_status === ReviewStatus.CHECKED,
|
.filter((s) => s.review_status === ReviewStatus.CHECKED)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.checked_at ?? "").getTime() -
|
||||||
|
new Date(a.checked_at ?? "").getTime(),
|
||||||
),
|
),
|
||||||
[submissionsQuery.data],
|
[submissionsQuery.data],
|
||||||
);
|
);
|
||||||
@@ -49,6 +57,7 @@ const ReviewPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TokenContext.Provider value={token}>
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<div className="mx-auto max-w-5xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
<ReviewHeader reviewer={reviewerQuery.data} />
|
<ReviewHeader reviewer={reviewerQuery.data} />
|
||||||
@@ -57,7 +66,7 @@ const ReviewPage = () => {
|
|||||||
defaultValue="available"
|
defaultValue="available"
|
||||||
className="my-3 flex flex-col items-stretch gap-6"
|
className="my-3 flex flex-col items-stretch gap-6"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 className="text-3xl font-semibold">Решения</h1>
|
<h1 className="text-3xl font-semibold">Решения</h1>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
@@ -85,7 +94,16 @@ const ReviewPage = () => {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</TokenContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useToken = () => {
|
||||||
|
const token = React.useContext(TokenContext);
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("useToken must be used within a TokenContext.Provider");
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
export default ReviewPage;
|
export default ReviewPage;
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import React from "react";
|
||||||
|
import { useToken } from "..";
|
||||||
|
import { getReviewSubmission, postReviewEvaluation } from "@/shared/api/review";
|
||||||
|
import { Loading } from "@/components/ui/loading";
|
||||||
|
import {
|
||||||
|
Review,
|
||||||
|
ReviewCriteria,
|
||||||
|
ReviewEvaluation,
|
||||||
|
} from "@/shared/types/review";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { ofetch } from "ofetch";
|
||||||
|
import { File } from "lucide-react";
|
||||||
|
|
||||||
|
interface ReviewDialogProps {
|
||||||
|
reviewId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReviewDialog = ({ reviewId, children }: ReviewDialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger>{children}</DialogTrigger>
|
||||||
|
<DialogContent className="h-[calc(100%-2rem)] max-h-[1000px] overflow-hidden p-0">
|
||||||
|
<ReviewScreen reviewId={reviewId} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReviewScreen = ({ reviewId }: { reviewId: string }) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const token = useToken();
|
||||||
|
|
||||||
|
const { data: review, isLoading } = useQuery({
|
||||||
|
queryKey: ["review", reviewId],
|
||||||
|
queryFn: async () => getReviewSubmission(token, reviewId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [evaluation, setEvaluation] = React.useState<{
|
||||||
|
[key: string]: ReviewEvaluation;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (review?.evaluation) {
|
||||||
|
setEvaluation(
|
||||||
|
review.evaluation.reduce(
|
||||||
|
(acc, e) => {
|
||||||
|
acc[e.slug] = e;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as { [key: string]: ReviewEvaluation },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [review?.evaluation]);
|
||||||
|
|
||||||
|
const onSubmit = React.useCallback(async () => {
|
||||||
|
const e: ReviewEvaluation[] | undefined = review?.criteries?.map((c) => {
|
||||||
|
return (
|
||||||
|
evaluation[c.slug] ?? {
|
||||||
|
slug: c.slug,
|
||||||
|
mark: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await postReviewEvaluation(token, reviewId, e);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["submissions", token],
|
||||||
|
});
|
||||||
|
}, [review?.criteries, evaluation, token, queryClient]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!review) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["submissions", token],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-h-full flex-col overflow-hidden">
|
||||||
|
<div className="flex flex-1 flex-col gap-7 overflow-y-auto px-8 py-7">
|
||||||
|
<ReviewHeader review={review} />
|
||||||
|
<ReviewDescription review={review} />
|
||||||
|
<ReviewContent review={review} />
|
||||||
|
<ReviewCriteriesList
|
||||||
|
review={review}
|
||||||
|
evaluation={evaluation}
|
||||||
|
setEvaluation={setEvaluation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ReviewFooter
|
||||||
|
evaluation={evaluation}
|
||||||
|
criteries={review.criteries}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReviewHeader = ({ review }: { review: Review }) => {
|
||||||
|
const id = review.id.split("-").at(-1)?.slice(0, 6);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3.5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-muted-foreground text-lg font-semibold">
|
||||||
|
{review.competition_name}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-4xl font-semibold">{review.task_title}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground flex gap-2 font-semibold">
|
||||||
|
<span>{id}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{dayjs(review.submitted_at).format("D MMMM, HH:mm")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReviewDescription = ({ review }: { review: Review }) => {
|
||||||
|
if (!review.description) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<h2 className="text-3xl font-semibold">Условие</h2>
|
||||||
|
<div className="bg-background rounded-xl px-5 py-3 text-lg">
|
||||||
|
{review.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReviewContent = ({ review }: { review: Review }) => {
|
||||||
|
const extension = review.content.split(".").at(-1);
|
||||||
|
const filename = review.content.split("/").at(-1);
|
||||||
|
|
||||||
|
const { data: content, isLoading } = useQuery({
|
||||||
|
queryKey: ["review-file", review.id],
|
||||||
|
queryFn: async () => await ofetch(review.content),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<h2 className="text-3xl font-semibold">Ответ</h2>
|
||||||
|
|
||||||
|
<div className="bg-background rounded-xl px-5 py-3 text-lg">
|
||||||
|
{extension === "txt" ? (
|
||||||
|
content
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={review.content}
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<File size={16} />
|
||||||
|
<span>{filename}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReviewCriteriesList = ({
|
||||||
|
review,
|
||||||
|
evaluation,
|
||||||
|
setEvaluation,
|
||||||
|
}: {
|
||||||
|
review: Review;
|
||||||
|
evaluation: { [key: string]: ReviewEvaluation };
|
||||||
|
setEvaluation: React.Dispatch<
|
||||||
|
React.SetStateAction<{
|
||||||
|
[key: string]: ReviewEvaluation;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
}) => {
|
||||||
|
const onChange = React.useCallback(
|
||||||
|
(slug: string, value?: number) => {
|
||||||
|
if (!value || isNaN(value)) {
|
||||||
|
setEvaluation((prev) => ({ ...prev, [slug]: { slug, mark: 0 } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value < 0 ||
|
||||||
|
value >
|
||||||
|
(review.criteries?.filter((c) => c.slug === slug).at(0)?.max_value ??
|
||||||
|
0)
|
||||||
|
) {
|
||||||
|
return setEvaluation((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[slug]: { slug, mark: 0 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvaluation((prev) => ({ ...prev, [slug]: { slug, mark: value } }));
|
||||||
|
},
|
||||||
|
[evaluation],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h2 className="text-3xl font-semibold">Критерии</h2>
|
||||||
|
<div className="flex flex-col items-stretch gap-5">
|
||||||
|
{review.criteries?.map((c) => {
|
||||||
|
const value = evaluation[c.slug]?.mark;
|
||||||
|
return (
|
||||||
|
<Criteria
|
||||||
|
key={c.slug}
|
||||||
|
criteria={c}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Criteria = ({
|
||||||
|
criteria,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
criteria: ReviewCriteria;
|
||||||
|
value?: number;
|
||||||
|
onChange?: (slug: string, value: number) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<h3 className="text-lg">{criteria.name}</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Максимальное значение — {criteria.max_value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
placeholder={criteria.max_value.toString()}
|
||||||
|
className="flex h-10 w-15 items-center rounded-xl border px-2 text-center"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange?.(criteria.slug, Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReviewFooter = ({
|
||||||
|
evaluation,
|
||||||
|
criteries,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
evaluation: { [key: string]: ReviewEvaluation };
|
||||||
|
criteries?: ReviewCriteria[];
|
||||||
|
onSubmit: () => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const score = Object.values(evaluation).reduce((acc, e) => acc + e.mark, 0);
|
||||||
|
const maxScore = criteries?.reduce((acc, c) => acc + c.max_value, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col items-stretch gap-7 px-8 py-6", {
|
||||||
|
"bg-correct *:text-correct-foreground [&>button]:bg-correct-foreground [&>button]:hover:bg-correct-foreground/80 [&>button]:text-correct":
|
||||||
|
score === maxScore,
|
||||||
|
"bg-partial *:text-partial-foreground [&>button]:bg-partial-foreground [&>button]:hover:bg-partial-foreground/80 [&>button]:text-partial":
|
||||||
|
score > 0 && score < (maxScore ?? 0),
|
||||||
|
"bg-wrong *:text-wrong-foreground [&>button]:bg-wrong-foreground [&>button]:hover:bg-wrong-foreground/80 [&>button]:text-wrong":
|
||||||
|
score === 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4 text-3xl font-semibold">
|
||||||
|
<h2>Итого</h2>
|
||||||
|
<h2 className="text-right">
|
||||||
|
{score <= 0 ? "Неверный ответ" : `Зачтено ${score}/${maxScore}`}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onSubmit}>Сохранить</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Review } from "@/shared/types/review";
|
import { Review } from "@/shared/types/review";
|
||||||
import { ReviewCard } from "../components/review-card";
|
import { ReviewCard } from "../components/review-card";
|
||||||
import { NoReviews } from "./no-reviews";
|
import { NoReviews } from "./no-reviews";
|
||||||
|
import { ReviewDialog } from "./review-dialog";
|
||||||
|
|
||||||
interface ReviewsListProp {
|
interface ReviewsListProp {
|
||||||
reviews: Review[];
|
reviews: Review[];
|
||||||
@@ -14,7 +15,9 @@ export const ReviewsList = ({ reviews }: ReviewsListProp) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-stretch gap-5">
|
<div className="flex flex-col items-stretch gap-5">
|
||||||
{reviews.map((review) => (
|
{reviews.map((review) => (
|
||||||
<ReviewCard key={review.id} review={review} />
|
<ReviewDialog key={review.id} reviewId={review.id}>
|
||||||
|
<ReviewCard review={review} />
|
||||||
|
</ReviewDialog>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ export const getCompetition = async (id: string) => {
|
|||||||
|
|
||||||
export const startCompetition = async (competitionId: string) => {
|
export const startCompetition = async (competitionId: string) => {
|
||||||
return await userFetch(`/competitions/${competitionId}/start`, {
|
return await userFetch(`/competitions/${competitionId}/start`, {
|
||||||
method: 'POST'
|
method: "POST",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,12 +1,29 @@
|
|||||||
import { apiFetch } from ".";
|
import { apiFetch } from ".";
|
||||||
import { Review, Reviewer } from "../types/review";
|
import { Review, Reviewer, ReviewEvaluation } from "../types/review";
|
||||||
|
|
||||||
export const getReviewer = async (token: string) => {
|
export const getReviewer = async (token: string) => {
|
||||||
return await apiFetch<Reviewer>(`/review/${token}`);
|
return await apiFetch<Reviewer>(`/review/${token}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getReviewerSubmissions = async (token: string) => {
|
export const getReviewSubmissions = async (token: string) => {
|
||||||
return await apiFetch<{ submissions: Review[] }>(
|
return await apiFetch<{ submissions: Review[] }>(
|
||||||
`/review/${token}/submissions`,
|
`/review/${token}/submissions`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getReviewSubmission = async (token: string, reviewId: string) => {
|
||||||
|
return await apiFetch<Review>(`/review/${token}/submissions/${reviewId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postReviewEvaluation = async (
|
||||||
|
token: string,
|
||||||
|
reviewId: string,
|
||||||
|
evaluation: ReviewEvaluation[],
|
||||||
|
) => {
|
||||||
|
return await apiFetch(`/review/${token}/submissions/${reviewId}/evaluate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
evaluation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface Review {
|
|||||||
review_status: ReviewStatus;
|
review_status: ReviewStatus;
|
||||||
evaluation?: ReviewEvaluation[];
|
evaluation?: ReviewEvaluation[];
|
||||||
criteries?: ReviewCriteria[];
|
criteries?: ReviewCriteria[];
|
||||||
submitted_at: Date;
|
submitted_at: string;
|
||||||
competition: string;
|
competition: string;
|
||||||
competition_name: string;
|
competition_name: string;
|
||||||
task: string;
|
task: string;
|
||||||
@@ -17,8 +17,9 @@ export interface Review {
|
|||||||
stdout?: string;
|
stdout?: string;
|
||||||
result?: {};
|
result?: {};
|
||||||
earned_points?: number;
|
earned_points?: number;
|
||||||
checked_at?: Date;
|
checked_at?: string;
|
||||||
task_title: string;
|
task_title: string;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ReviewStatus {
|
export enum ReviewStatus {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
@import "./fonts.css";
|
@import "./fonts.css";
|
||||||
@plugin "tailwindcss-animate";
|
@plugin "tailwindcss-animate";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(0.97 0 0);
|
--background: oklch(0.97 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
@@ -50,45 +48,26 @@
|
|||||||
--task-text-partial: oklch(0.639 0.1595 124.48);
|
--task-text-partial: oklch(0.639 0.1595 124.48);
|
||||||
--task-wrong: oklch(0.906 0.0484 18.08);
|
--task-wrong: oklch(0.906 0.0484 18.08);
|
||||||
--task-text-wrong: oklch(0.433 0.17767 29.2339);
|
--task-text-wrong: oklch(0.433 0.17767 29.2339);
|
||||||
|
|
||||||
|
--correct: #d4ffe5;
|
||||||
|
--correct-foreground: #009b1c;
|
||||||
|
|
||||||
|
--partial: #e7ffd4;
|
||||||
|
--partial-foreground: #779b00;
|
||||||
|
|
||||||
|
--wrong: #ffd4d4;
|
||||||
|
--wrong-foreground: #9b0000;
|
||||||
|
|
||||||
|
--checking: #ffffff;
|
||||||
|
--checking-foreground: #242424;
|
||||||
|
|
||||||
|
--review: #ffec9f;
|
||||||
|
--review-foreground: #9b7700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-hse-sans: "HSE Sans", system-ui, sans-serif;
|
--font-hse-sans: "HSE Sans", system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
.dark {
|
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.145 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.145 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.985 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.396 0.141 25.723);
|
|
||||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
|
||||||
--border: oklch(0.269 0 0);
|
|
||||||
--input: oklch(0.269 0 0);
|
|
||||||
--ring: oklch(0.439 0 0);
|
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(0.269 0 0);
|
|
||||||
--sidebar-ring: oklch(0.439 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
@@ -140,11 +119,26 @@
|
|||||||
--color-task-text-partial: var(--task-text-partial);
|
--color-task-text-partial: var(--task-text-partial);
|
||||||
--color-task-wrong: var(--task-wrong);
|
--color-task-wrong: var(--task-wrong);
|
||||||
--color-task-text-wrong: var(--task-text-wrong);
|
--color-task-text-wrong: var(--task-text-wrong);
|
||||||
|
|
||||||
|
--color-correct: var(--correct);
|
||||||
|
--color-correct-foreground: var(--correct-foreground);
|
||||||
|
|
||||||
|
--color-partial: var(--partial);
|
||||||
|
--color-partial-foreground: var(--partial-foreground);
|
||||||
|
|
||||||
|
--color-wrong: var(--wrong);
|
||||||
|
--color-wrong-foreground: var(--wrong-foreground);
|
||||||
|
|
||||||
|
--color-checking: var(--checking);
|
||||||
|
--color-checking-foreground: var(--checking-foreground);
|
||||||
|
|
||||||
|
--color-review: var(--review);
|
||||||
|
--color-review-foreground: var(--review-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50 font-hse-sans;
|
@apply border-border outline-ring/50 font-hse-sans scheme-light;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
|||||||
Reference in New Issue
Block a user