Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

과제 관리 #151

Merged
merged 14 commits into from
Feb 21, 2025
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "prettier", "@tanstack/query"],
"plugins": ["@typescript-eslint", "@tanstack/query"],
"parserOptions": {
"project": ["./tsconfig.json"],
"createDefaultProgram": true
Expand Down
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@
},
"editor.quickSuggestions": {
"strings": true
}
},
"typescript.tsdk": "node_modules/typescript/lib"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
'use client';

import Link from 'next/link';
import {
CalendarIcon,
FileTextIcon,
UserIcon,
ArrowLeftIcon,
} from 'lucide-react';
import { Button } from '@app/_shadcn/components/ui/button';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from '@app/_shadcn/components/ui/card';
import { Badge } from '@app/_shadcn/components/ui/badge';
import { NEW_PATH } from '@app/_constants/urls';

import useMyInfo from '@app/_hooks/apis/user/useMyInfo';
import Spinner from '@app/_components/Spinner';
import { useAssignmentDetail } from '@app/_hooks/apis/assignment/useAssignment';

import { formatDate, differenceInMilliseconds, parseISO } from 'date-fns';
import { useEffect, useState } from 'react';
import { HOUR, MINUTE, SECOND } from '@app/_constants/time';
import FileAttachment from '@app/_components/common/FileAttaachment';
import MoreOptionMenu from '@app/_components/common/MoreOptionMenu';
import { useMutation } from '@tanstack/react-query';
import assignmentService from '@app/_service/assignmentService';
import { useToast } from '@app/_shadcn/components/ui/use-toast';
import { useParams, useRouter } from 'next/navigation';

interface Props {
assignmentId: number;
}

export default function AssignmentDetail({ assignmentId }: Props) {
const { data: assignmentDetail, isLoading } = useAssignmentDetail({
assignmentId,
});
const { push } = useRouter();
const { toast } = useToast();
const { mutate: deleteAssignment } = useMutation({
mutationFn: () => assignmentService.deleteAssignment(assignmentId),
onSuccess: () => {
toast({
title: '과제 삭제 완료',
});
push(NEW_PATH.assignmentList.url(1));
},
onError: () => {
toast({
title: '과제 삭제 실패',
});
},
});

const { data: myInfo } = useMyInfo();

if (isLoading) return <Spinner />;
if (!assignmentDetail) return <> 데이터 없음 </>;

const isAuthor = myInfo?.id === assignmentDetail.assignment.userId;
const { files, assignment, submits } = assignmentDetail;
const deadline = parseISO(assignment.deadline);
const isDeadline = deadline < new Date();

const mySubmit = submits?.find((submit) => submit.userId === myInfo?.id);

return (
<Card className="mx-auto mb-8 max-w-4xl">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="mb-2 text-2xl">{assignment.title}</CardTitle>
{isAuthor && (
<MoreOptionMenu
editHref={NEW_PATH.assignmentEdit.url(assignment.assignmentId)}
onDelete={deleteAssignment}
/>
)}
</div>
<CardDescription>{assignment.category}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 ">
<p className=" text-gray-600">{assignment.description}</p>
<FileAttachment files={files} />

<div className="flex items-center justify-between text-xl font-semibold">
{isDeadline ? (
<Badge variant="destructive">마감됨</Badge>
) : (
<div className="flex items-center ">
<CalendarIcon className="text-blue-500" />
<span>
마감일: <CountDown deadline={deadline} />
</span>
</div>
)}
<span className="text-sm text-muted-foreground">
{formatDate(deadline, 'yyyy-MM-dd HH:mm')} 까지
</span>
</div>

<div className="mb-6 flex items-center text-sm text-muted-foreground">
<UserIcon className="mr-2 h-4 w-4" />
작성자: {assignment.name}
</div>
</CardContent>
<CardFooter>
<ActionButton isAuthor={isAuthor} isSubmitted={!!mySubmit} />
</CardFooter>
</Card>
);
}

function CountDown({ deadline }: { deadline: Date }) {
const [timeLeftInMs, setTimeLeftInMs] = useState(0);

useEffect(() => {
const calculateTimeLeft = () => {
const now = new Date();
const diffMs = differenceInMilliseconds(deadline, now);
setTimeLeftInMs(diffMs);
};

calculateTimeLeft();
const timer = setInterval(calculateTimeLeft, 1000);

return () => clearInterval(timer);
}, [deadline]);

const days = Math.floor(timeLeftInMs / (24 * HOUR));
const hours = Math.floor((timeLeftInMs % (24 * HOUR)) / HOUR);
const minutes = Math.floor((timeLeftInMs % HOUR) / MINUTE);
const seconds = Math.floor((timeLeftInMs % MINUTE) / SECOND);

if (timeLeftInMs < 0) {
return null;
}

return (
<span>
{days > 0 && `${days.toString()}일 `}
{hours > 0 && `${hours.toString().padStart(2, '0')}: `}
{minutes > 0 && `${minutes.toString().padStart(2, '0')}: `}
{seconds > 0 && `${seconds.toString().padStart(2, '0')}`}
남음
</span>
);
}

/**
* 모두 -> 과제 목록 돌아가기
* 준회원 -> 제출 없음 -> 제출하기
*/
function ActionButton({
isSubmitted,
isAuthor,
}: {
isSubmitted: boolean;
isAuthor: boolean;
}) {
const { assignmentId } = useParams();

return (
<div className="flex w-full flex-col items-center justify-center gap-4">
{!isSubmitted && !isAuthor && (
<Button size="lg" asChild className="w-full">
<Link
href={NEW_PATH.submitCreate.url({
assignmentId: Number(assignmentId),
})}
>
<FileTextIcon className="mr-2 h-4 w-4" />
과제 제출
</Link>
</Button>
)}
<Button size="lg" variant="secondary" className="w-full" asChild>
<Link href={NEW_PATH.assignmentList.url(1)}>
<ArrowLeftIcon className="mr-2 h-4 w-4" />
과제 목록으로 돌아가기
</Link>
</Button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
'use client';

import Link from 'next/link';
import { DownloadIcon, UserIcon } from 'lucide-react';
import { Button } from '@app/_shadcn/components/ui/button';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@app/_shadcn/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@app/_shadcn/components/ui/table';
import { Input } from '@app/_shadcn/components/ui/input';
import { Badge } from '@app/_shadcn/components/ui/badge';
import { NEW_PATH } from '@app/_constants/urls';
import { useParams } from 'next/navigation';
import { useAssignmentDetail } from '@app/_hooks/apis/assignment/useAssignment';
import Spinner from '@app/_components/Spinner';
import { formatDate } from 'date-fns';
import useMyInfo from '@app/_hooks/apis/user/useMyInfo';
import FileAttachment from '@app/_components/common/FileAttaachment';
import { useMutation } from '@tanstack/react-query';
import assignmentService from '@app/_service/assignmentService';
import { useForm } from 'react-hook-form';
import { FormField } from '@app/_shadcn/components/ui/form';
import { useToast } from '@app/_shadcn/components/ui/use-toast';

export default function SubmitList() {
const { data: myInfo } = useMyInfo();

const params = useParams<{ assignmentId: string; submitId: string }>();

const { toast } = useToast();

const { mutate: updateAllScoreMutate, isPending } = useMutation({
mutationFn: (body: { submitId: number; score: number }[]) => {
return assignmentService.updateAllScore(body);
},
});

const { data: assignmentDetail, isLoading } = useAssignmentDetail({
assignmentId: Number(params.assignmentId),
});

const scores: { [submitId: string]: number } = {};
assignmentDetail?.submits.forEach(({ submitId, score }) => {
scores[submitId] = score ?? 0;
});

const { control, handleSubmit } = useForm<{
[submitId: string]: number;
}>({ defaultValues: scores });

if (isLoading) return <Spinner />;

if (!myInfo) return null;

const submissions = assignmentDetail?.submits || [];

const updateAllScore = (data: { [submitId: string]: number }) => {
const body = Object.entries(data).map(([submitId, score]) => ({
submitId: Number(submitId),
score,
}));
updateAllScoreMutate(body, {
onSuccess: () => {
toast({
title: '점수가 저장되었어요.',
});
},
onError: () => {
toast({
title: '점수 저장에 실패했어요.',
});
},
});
};

return (
<form onSubmit={handleSubmit(updateAllScore)}>
<Card className="relative mx-auto max-w-4xl ">
<CardHeader className="sticky top-12 z-10 bg-background">
{myInfo.role === 'associate' ? (
<CardTitle className="flex items-center justify-between text-xl">
제출 정보
</CardTitle>
) : (
<CardTitle className="flex items-center justify-between bg-background text-xl">
<div className="flex items-center gap-2">
<span className="flex items-center">
<UserIcon className="mr-2 h-5 w-5" />
제출자 목록
</span>
<Badge variant="secondary">
총 제출 수: {submissions.length}
</Badge>
</div>
<div className="flex gap-2">
<Button variant="outline">
<DownloadIcon className="mr-2 size-4" />
모든 파일 다운로드
</Button>
<Button type="submit" disabled={isPending}>
점수 저장 {isPending && <Spinner />}
</Button>
</div>
</CardTitle>
)}
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>이름</TableHead>
<TableHead>제출일</TableHead>
<TableHead>점수</TableHead>
<TableHead>파일</TableHead>
<TableHead className="text-right">액션</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissions.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center">
제출 정보가 없습니다.
</TableCell>
</TableRow>
)}
{submissions?.map((submission) => (
<TableRow key={submission.submitId}>
<TableCell className="font-medium">
{submission.name}
</TableCell>
<TableCell>
{formatDate(
new Date(submission.submitDate),
'M월 dd일 HH:mm',
)}
</TableCell>
<TableCell>
<FormField
control={control}
name={String(submission.submitId)}
render={({ field }) => (
<Input
type="number"
className="w-20"
min="0"
max="100"
{...field}
value={field.value ?? ''}
onChange={(e) => {
field.onChange(Number(e.target.value));
}}
/>
)}
/>
</TableCell>
<TableCell>
<FileAttachment size="sm" files={submission.files} />
</TableCell>
<TableCell className="text-right">
<Button asChild variant="outline" size="sm">
<Link
href={NEW_PATH.submitDetail.url({
assignmentId: Number(params.assignmentId),
submitId: submission.submitId,
})}
>
상세보기
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</form>
);
}
Loading