Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export interface FeedbackData {
interviewId?: number;
sessionId?: string;
style?: string;
level?: string;
status?: string;
totalScore?: number;
intentAlignmentScore?: number;
Expand Down
108 changes: 108 additions & 0 deletions src/features/feedback-page/feedback-list/model/sortFeedbackList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { FeedbackListItem } from "@/widgets/feedback-page/feedback-list/type";

export type FeedbackListSortKey = "date" | "score";
export type FeedbackListSortOrder = "asc" | "desc";

export interface FeedbackListSort {
key: FeedbackListSortKey;
order: FeedbackListSortOrder;
}

export const DEFAULT_FEEDBACK_LIST_SORT: FeedbackListSort = {
key: "date",
order: "desc",
};

export const FEEDBACK_LIST_SORT_KEY_LABELS: Record<FeedbackListSortKey, string> = {
date: "날짜",
score: "종합 점수",
};

const SORT_ORDER_LABELS: Record<FeedbackListSortKey, Record<FeedbackListSortOrder, string>> = {
date: { desc: "최신순", asc: "오래된 순" },
score: { desc: "높은 점수순", asc: "낮은 점수순" },
};

export const getFeedbackListSortLabel = ({ key, order }: FeedbackListSort) =>
SORT_ORDER_LABELS[key][order];

const isSortKey = (value: string | null): value is FeedbackListSortKey =>
value === "date" || value === "score";

const isSortOrder = (value: string | null): value is FeedbackListSortOrder =>
value === "asc" || value === "desc";

// 쿼리가 없거나 값이 틀리면 기본값(날짜 · 내림차순)을 쓴다.
export const readFeedbackListSort = (
searchParams: URLSearchParams,
): FeedbackListSort => {
const key = searchParams.get("sort");
const order = searchParams.get("order");

return isSortKey(key) && isSortOrder(order)
? { key, order }
: DEFAULT_FEEDBACK_LIST_SORT;
};

// 기본값이면 sort·order 쿼리를 붙이지 않는다.
export const writeFeedbackListSort = (
searchParams: URLSearchParams,
sort: FeedbackListSort,
) => {
const next = new URLSearchParams(searchParams);

if (
sort.key === DEFAULT_FEEDBACK_LIST_SORT.key &&
sort.order === DEFAULT_FEEDBACK_LIST_SORT.order
) {
next.delete("sort");
next.delete("order");
} else {
next.set("sort", sort.key);
next.set("order", sort.order);
}

return next;
};

const getCreatedTime = (item: FeedbackListItem) => {
const time = item.createdAt ? Date.parse(item.createdAt) : Number.NaN;

return Number.isNaN(time) ? 0 : time;
};

const getScore = (item: FeedbackListItem) =>
typeof item.totalScore === "number" && Number.isFinite(item.totalScore)
? item.totalScore
: null;

export const sortFeedbackList = <T extends FeedbackListItem>(
items: readonly T[],
{ key, order }: FeedbackListSort,
): T[] => {
const direction = order === "asc" ? 1 : -1;
const latestFirst = (left: T, right: T) =>
getCreatedTime(right) - getCreatedTime(left);

if (key === "date") {
return [...items].sort(
(left, right) => direction * (getCreatedTime(left) - getCreatedTime(right)),
);
}

// 점수가 없는 항목은 방향과 관계없이 맨 뒤, 그 안과 동점은 최신순.
return [...items].sort((left, right) => {
const leftScore = getScore(left);
const rightScore = getScore(right);

if (leftScore === null || rightScore === null) {
if (leftScore !== rightScore) {
return leftScore === null ? 1 : -1;
}

return latestFirst(left, right);
}

return direction * (leftScore - rightScore) || latestFirst(left, right);
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import {
type FeedbackData,
} from "@/features/feedback-page/feedback-list/api/getAllInterviews";
import {
getCareerLabel,
getFormattedDate,
getInterviewStatusLabel,
getInterviewStyleLabel,
getInterviewModeLabel,
getInterviewId,
getInterviewerName,
getLevelLabel,
sortInterviewsByCreatedAt,
} from "@/features/feedback-page/model/interviewDisplay";
import { extractErrorMessage } from "@/shared/api/errorMessage";
Expand Down Expand Up @@ -70,6 +70,7 @@ const getListItems = (
id: targetInterviewId,
companyName: readCompanyBadge(interview.company)?.displayName,
date: getFormattedDate(interview.createdAt),
createdAt: interview.createdAt,
imageUrl:
interview.persona?.imageUrl ??
getInterviewerImageUrl(
Expand All @@ -81,7 +82,7 @@ const getListItems = (
styleLabel: getInterviewStyleLabel(
feedback?.style ?? interview.persona?.type,
),
levelLabel: getCareerLabel(interview.persona?.career),
levelLabel: getLevelLabel(feedback?.level ?? interview.persona?.level),
interviewerName,
statusLabel: feedback
? getInterviewStatusLabel(feedback.status)
Expand Down
39 changes: 22 additions & 17 deletions src/features/feedback-page/model/interviewDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const STATUS_LABELS: Record<string, string> = {
READY: "대기중",
PENDING: "분석중",
FAILED: "분석실패",
ABANDONED: "중단됨",
};

const getSafeDate = (value?: string) => {
Expand Down Expand Up @@ -95,27 +96,31 @@ export const getInterviewStyleLabel = (type?: string) => {
return PERSONA_TYPE_LABELS[type] ?? "성향 미정";
};

export const getCareerLabel = (career?: number) => {
if (career === undefined || career === null) {
return "미제공";
}
const LEVEL_LABELS: Record<string, string> = {
EASY: "쉬움",
NORMAL: "보통",
HARD: "어려움",
};

if (career <= 1) {
return "쉬움";
}
export const getLevelLabel = (level?: string | null) =>
(level ? LEVEL_LABELS[level] : undefined) ?? "미제공";

if (career <= 3) {
return "보통";
}
// SOLO 페르소나 이름은 유일성을 위해 끝에 -{uuid}가 붙어 저장된다.
const PERSONA_NAME_UUID_SUFFIX =
/-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

return "어려움";
};
export const getPersonaDisplayName = (personaName?: string | null) =>
personaName?.trim().replace(PERSONA_NAME_UUID_SUFFIX, "").trim() ?? "";

export const getInterviewerName = (interview: InterviewSummary) => {
const count = getInterviewerCount(interview);
const personaName =
interview.mode === "MULTI"
? ""
: getPersonaDisplayName(interview.persona?.personaName);

export const getInterviewerName = (interview: InterviewSummary) =>
interview.persona?.personaName?.trim() ||
(getInterviewerCount(interview) > 0
? `면접관 ${getInterviewerCount(interview)}명`
: "면접관 정보 없음");
return personaName || (count > 0 ? `면접관 ${count}명` : "면접관 정보 없음");
};

export const getInterviewStatusLabel = (status?: string) => {
if (!status) {
Expand Down
83 changes: 52 additions & 31 deletions src/widgets/feedback-page/feedback-list/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMemo } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
FEEDBACK_LIST_SORT_KEY_LABELS,
getFeedbackListSortLabel,
readFeedbackListSort,
sortFeedbackList,
writeFeedbackListSort,
type FeedbackListSort,
type FeedbackListSortKey,
} from "@/features/feedback-page/feedback-list/model/sortFeedbackList";
import * as S from "./style";
import type { FeedbackListProps } from "./type";

type SortOrder = "oldest" | "latest";

const getDateTime = (date: string) => {
const parsedDate = new Date(date.split("/").join("-"));

return Number.isNaN(parsedDate.getTime()) ? 0 : parsedDate.getTime();
};

const getNormalizedStatusLabel = (statusLabel: string) =>
statusLabel.replace(/\s/g, "");

Expand Down Expand Up @@ -40,37 +41,57 @@ const FeedbackList = ({
onItemClick,
}: InternalProps) => {
const navigate = useNavigate();
const [sortOrder, setSortOrder] = useState<SortOrder>("oldest");
const [searchParams, setSearchParams] = useSearchParams();
const sort = readFeedbackListSort(searchParams);
const sortedItems = useMemo(
() =>
[...items].sort((left, right) => {
const leftTime = getDateTime(left.date);
const rightTime = getDateTime(right.date);

return sortOrder === "oldest"
? leftTime - rightTime
: rightTime - leftTime;
}),
[items, sortOrder],
() => sortFeedbackList(items, readFeedbackListSort(searchParams)),
[items, searchParams],
);
const updateSort = (nextSort: FeedbackListSort) => {
setSearchParams(
(current) => writeFeedbackListSort(current, nextSort),
{ replace: true },
);
};

return (
<S.Page>
<S.Panel>
<S.Toolbar>
<S.SectionTitle>{title}</S.SectionTitle>
<S.SortSelectWrapper>
<S.SortSelect
aria-label="면접 목록 정렬"
value={sortOrder}
onChange={(event) =>
setSortOrder(event.target.value as SortOrder)
<S.SortControls>
<S.SortSelectWrapper>
<S.SortSelect
aria-label="정렬 기준"
value={sort.key}
onChange={(event) =>
// 기준을 바꾸면 방향은 내림차순으로 되돌린다.
updateSort({
key: event.target.value as FeedbackListSortKey,
order: "desc",
})
}
>
{(Object.keys(FEEDBACK_LIST_SORT_KEY_LABELS) as FeedbackListSortKey[]).map((key) => (
<option key={key} value={key}>
{FEEDBACK_LIST_SORT_KEY_LABELS[key]}
</option>
))}
</S.SortSelect>
</S.SortSelectWrapper>
<S.SortDirectionButton
type="button"
aria-label={`정렬 방향: ${getFeedbackListSortLabel(sort)}`}
onClick={() =>
updateSort({
key: sort.key,
order: sort.order === "desc" ? "asc" : "desc",
})
}
>
<option value="oldest">오래된 순</option>
<option value="latest">최신순</option>
</S.SortSelect>
</S.SortSelectWrapper>
{getFeedbackListSortLabel(sort)}
</S.SortDirectionButton>
</S.SortControls>
</S.Toolbar>

{isLoading ? (
Expand Down
22 changes: 22 additions & 0 deletions src/widgets/feedback-page/feedback-list/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@ export const SortSelect = styled.select`
${focusRing}
`;

export const SortControls = styled.div`
display: inline-flex;
align-items: center;
gap: 0.5rem;
`;

export const SortDirectionButton = styled.button`
min-height: 2rem;
border: 0.0625rem solid rgba(26, 115, 232, 0.32);
border-radius: 0.35rem;
background: transparent;
color: #1a73e8;
cursor: pointer;
font-family: inherit;
font-size: 1rem;
font-weight: 800;
line-height: 1;
padding: 0.3rem 0.6rem;

${focusRing}
`;

export const List = styled.div`
width: 100%;
display: grid;
Expand Down
1 change: 1 addition & 0 deletions src/widgets/feedback-page/feedback-list/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface FeedbackListItem {
companyName?: string;
id: number;
date: string;
createdAt?: string;
imageUrl: string;
title: string;
styleLabel: string;
Expand Down
Loading