Refactor: 리뷰 페이지 디자인 개선 및 시간 표시 통일
This commit is contained in:
parent
76fd7cf419
commit
b35f67bc53
@ -1,14 +1,15 @@
|
|||||||
import { Briefcase, Tag, Box, Layers } from 'lucide-react';
|
import { Briefcase, Tag, Box, Layers, Check, X } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import useProjectQuery from '@/queries/projects/useProjectQuery';
|
import useProjectQuery from '@/queries/projects/useProjectQuery';
|
||||||
import useAuthStore from '@/stores/useAuthStore';
|
import useAuthStore from '@/stores/useAuthStore';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import formatDateTime from '@/utils/formatDateTime';
|
|
||||||
import { ReviewStatus } from '@/types';
|
import { ReviewStatus } from '@/types';
|
||||||
|
import timeAgo from '@/utils/timeAgo';
|
||||||
|
|
||||||
interface ReviewItemProps {
|
interface ReviewItemProps {
|
||||||
title: string;
|
title: string;
|
||||||
createdTime: string;
|
createdTime: string;
|
||||||
|
updatedTime: string;
|
||||||
creatorName: string;
|
creatorName: string;
|
||||||
projectId: number;
|
projectId: number;
|
||||||
status: ReviewStatus;
|
status: ReviewStatus;
|
||||||
@ -26,6 +27,7 @@ const typeIcons: Record<'classification' | 'detection' | 'segmentation', JSX.Ele
|
|||||||
export default function ReviewItem({
|
export default function ReviewItem({
|
||||||
title,
|
title,
|
||||||
createdTime,
|
createdTime,
|
||||||
|
updatedTime,
|
||||||
creatorName,
|
creatorName,
|
||||||
projectId,
|
projectId,
|
||||||
status,
|
status,
|
||||||
@ -45,15 +47,17 @@ export default function ReviewItem({
|
|||||||
>
|
>
|
||||||
<div className="flex h-[100px] w-full items-center justify-between border-b-[0.67px] border-[#ececef] bg-[#fbfafd] p-4">
|
<div className="flex h-[100px] w-full items-center justify-between border-b-[0.67px] border-[#ececef] bg-[#fbfafd] p-4">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p className="text-sm font-semibold text-black">{title}</p>
|
<p className="body-small-strong text-black">{title}</p>
|
||||||
<p className="mt-1 text-xs text-gray-500">by {creatorName}</p>
|
<p className="caption mt-1 text-gray-500">
|
||||||
|
Created {timeAgo(createdTime)} by {creatorName}
|
||||||
|
</p>
|
||||||
<div className="mt-1 flex items-center">
|
<div className="mt-1 flex items-center">
|
||||||
<Briefcase className="h-3 w-3 text-gray-500" />
|
<Briefcase className="h-3 w-3 text-gray-500" />
|
||||||
<p className="ml-1 text-xs text-gray-500">{projectData?.title}</p>
|
<p className="caption ml-1 text-gray-500">{projectData?.title}</p>
|
||||||
</div>
|
</div>
|
||||||
{type && (
|
{type && (
|
||||||
<div
|
<div
|
||||||
className="mt-1 inline-flex max-w-fit items-center gap-1 rounded-full px-3 py-1 text-xs text-white"
|
className="caption mt-1 inline-flex max-w-fit items-center gap-1 rounded-full px-3 py-1 text-white"
|
||||||
style={{ backgroundColor: type.color, padding: '1px 5px' }}
|
style={{ backgroundColor: type.color, padding: '1px 5px' }}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
@ -64,7 +68,7 @@ export default function ReviewItem({
|
|||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full px-3 py-0.5 text-center text-xs',
|
'caption flex items-center gap-1 rounded-full px-3 py-0.5',
|
||||||
status === 'APPROVED'
|
status === 'APPROVED'
|
||||||
? 'bg-green-100 text-green-600'
|
? 'bg-green-100 text-green-600'
|
||||||
: status === 'REJECTED'
|
: status === 'REJECTED'
|
||||||
@ -72,9 +76,10 @@ export default function ReviewItem({
|
|||||||
: 'bg-blue-100 text-blue-600'
|
: 'bg-blue-100 text-blue-600'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{status === 'APPROVED' ? <Check size={12} /> : status === 'REJECTED' ? <X size={12} /> : <></>}
|
||||||
{status}
|
{status}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Created at {formatDateTime(createdTime)}</p>
|
<p className="caption text-gray-500">Updated {timeAgo(updatedTime)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -60,6 +60,7 @@ export default function ReviewList({
|
|||||||
reviewId={item.reviewId}
|
reviewId={item.reviewId}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
createdTime={item.createdAt}
|
createdTime={item.createdAt}
|
||||||
|
updatedTime={item.updatedAt}
|
||||||
creatorName={item.author.nickname}
|
creatorName={item.author.nickname}
|
||||||
projectId={item.projectId}
|
projectId={item.projectId}
|
||||||
status={item.status}
|
status={item.status}
|
||||||
|
@ -10,6 +10,10 @@ import { Button } from '@/components/ui/button';
|
|||||||
import 'slick-carousel/slick/slick.css';
|
import 'slick-carousel/slick/slick.css';
|
||||||
import 'slick-carousel/slick/slick-theme.css';
|
import 'slick-carousel/slick/slick-theme.css';
|
||||||
import ImageWithLabels from '@/components/ImageWithLabels';
|
import ImageWithLabels from '@/components/ImageWithLabels';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Check, X } from 'lucide-react';
|
||||||
|
import formatDateTime from '@/utils/formatDateTime';
|
||||||
|
import timeAgo from '@/utils/timeAgo';
|
||||||
|
|
||||||
export default function ReviewDetail(): JSX.Element {
|
export default function ReviewDetail(): JSX.Element {
|
||||||
const { workspaceId, projectId, reviewId } = useParams<{
|
const { workspaceId, projectId, reviewId } = useParams<{
|
||||||
@ -59,13 +63,50 @@ export default function ReviewDetail(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="review-detail-container p-4">
|
<div className="review-detail-container p-4">
|
||||||
<div className="header mb-4">
|
<div className="header mb-4 flex flex-col gap-1">
|
||||||
<h1 className="heading mb-2">{reviewDetail.title}</h1>
|
<h1 className="heading mb-2">{reviewDetail.title}</h1>
|
||||||
<p className="body-small text-gray-500">
|
<div className="mb-1 flex gap-1">
|
||||||
작성자 : {reviewDetail.author.nickname} ({reviewDetail.author.email})
|
<div
|
||||||
</p>
|
className={cn(
|
||||||
<p className="body-small text-gray-500">작성일 : {new Date(reviewDetail.createdAt).toLocaleDateString()}</p>
|
'caption mr-1 flex items-center gap-1 rounded-full px-3 py-0.5',
|
||||||
<p className="body-small text-gray-500">수정일 : {new Date(reviewDetail.updatedAt).toLocaleDateString()}</p>
|
reviewDetail.reviewStatus === 'APPROVED'
|
||||||
|
? 'bg-green-100 text-green-600'
|
||||||
|
: reviewDetail.reviewStatus === 'REJECTED'
|
||||||
|
? 'bg-red-100 text-red-600'
|
||||||
|
: 'bg-blue-100 text-blue-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{reviewDetail.reviewStatus === 'APPROVED' ? (
|
||||||
|
<Check size={12} />
|
||||||
|
) : reviewDetail.reviewStatus === 'REJECTED' ? (
|
||||||
|
<X size={12} />
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
{reviewDetail.reviewStatus}
|
||||||
|
</div>
|
||||||
|
{reviewDetail.reviewStatus === 'APPROVED' || reviewDetail.reviewStatus === 'REJECTED' ? (
|
||||||
|
<>
|
||||||
|
<p className="body-small text-gray-500">by</p>
|
||||||
|
<p className="body-small-strong text-gray-500">
|
||||||
|
{reviewDetail.reviewer.nickname} ({reviewDetail.reviewer.email})
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="body-small text-gray-500">updated</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="body-small-strong text-gray-500">{timeAgo(reviewDetail.updatedAt)}</p>
|
||||||
|
<p className="body-small text-gray-500">({formatDateTime(reviewDetail.updatedAt)})</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<p className="body-small-strong text-gray-500">
|
||||||
|
{reviewDetail.author.nickname} ({reviewDetail.author.email})
|
||||||
|
</p>
|
||||||
|
<p className="body-small text-gray-500">requested a review</p>
|
||||||
|
<p className="body-small-strong text-gray-500">{timeAgo(reviewDetail.createdAt)}</p>
|
||||||
|
<p className="body-small text-gray-500">({formatDateTime(reviewDetail.createdAt)})</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
@ -111,28 +152,6 @@ export default function ReviewDetail(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(reviewDetail.reviewStatus === 'APPROVED' || reviewDetail.reviewStatus === 'REJECTED') && (
|
|
||||||
<div className="reviewer-info mt-6">
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
리뷰 상태: {reviewDetail.reviewStatus === 'APPROVED' ? '승인됨' : '거부됨'}
|
|
||||||
</h2>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<img
|
|
||||||
src={reviewDetail.reviewer.profileImage}
|
|
||||||
alt="리뷰어 프로필"
|
|
||||||
className="h-10 w-10 rounded-full"
|
|
||||||
/>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="font-bold">{reviewDetail.reviewer.nickname}</p>
|
|
||||||
<p className="text-gray-500">{reviewDetail.reviewer.email}</p>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
{reviewDetail.reviewStatus === 'APPROVED' ? '승인한 사람 : ' : '거부한 사람 : '}
|
|
||||||
{reviewDetail.reviewer.nickname}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
<Link to={`/admin/${workspaceId}/reviews`}>
|
<Link to={`/admin/${workspaceId}/reviews`}>
|
||||||
<Button variant="black">목록으로 돌아가기</Button>
|
<Button variant="black">목록으로 돌아가기</Button>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
export default function formatDateTime(dateTimeString: string): string {
|
export default function formatDateTime(dateTimeString: string): string {
|
||||||
const [date, time] = dateTimeString.split('T');
|
const [date, time] = dateTimeString.split('T');
|
||||||
const [hours, minutes] = time.split(':');
|
const [year, month, day] = date.split('-');
|
||||||
|
const [hours, minutes, seconds] = time.split(':');
|
||||||
|
|
||||||
return `${date} ${hours}:${minutes}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,20 @@
|
|||||||
export default function timeAgo(date: string | Date) {
|
export default function timeAgo(date: string | Date) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const past = new Date(date);
|
const past = new Date(date);
|
||||||
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
|
|
||||||
|
|
||||||
if (diffInSeconds < 60) return `${Math.max(diffInSeconds, 0)}초 전`;
|
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
|
||||||
|
if (diffInSeconds === 1) return `${Math.max(diffInSeconds, 0)} second ago`;
|
||||||
|
if (diffInSeconds < 60) return `${Math.max(diffInSeconds, 0)} seconds ago`;
|
||||||
|
|
||||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||||
if (diffInMinutes < 60) return `${diffInMinutes}분 전`;
|
if (diffInMinutes === 1) return `${diffInMinutes} minute ago`;
|
||||||
|
if (diffInMinutes < 60) return `${diffInMinutes} minutes ago`;
|
||||||
|
|
||||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||||
if (diffInHours < 24) return `${diffInHours}시간 전`;
|
if (diffInHours === 1) return `${diffInHours} hour ago`;
|
||||||
|
if (diffInHours < 24) return `${diffInHours} hours ago`;
|
||||||
|
|
||||||
const diffInDays = Math.floor(diffInHours / 24);
|
const diffInDays = Math.floor(diffInHours / 24);
|
||||||
return `${diffInDays}일 전`;
|
if (diffInDays === 1) return `${diffInDays} day ago`;
|
||||||
|
return `${diffInDays} days ago`;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user