Skip to content

Commit 3f8c2f5

Browse files
committed
시급 페이지 각 테이블에 즐겨찾기 기능 추가
1 parent cbc8a04 commit 3f8c2f5

4 files changed

Lines changed: 251 additions & 8 deletions

File tree

src/frontend/src/core/table/data-table.tsx

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
PaginationRoot,
1616
} from "~/core/chakra-components/pagination";
1717

18+
import { FavoriteValue } from "./favorite-control";
1819
import { SortControl } from "./sort-control";
1920

2021
export type DataTableProps<T> = TableHTMLAttributes<HTMLTableElement> & {
@@ -28,6 +29,8 @@ export type DataTableProps<T> = TableHTMLAttributes<HTMLTableElement> & {
2829
data: T;
2930
}[];
3031
pagination?: boolean;
32+
favoriteKeyPath?: string;
33+
favorites?: FavoriteValue[];
3134
};
3235

3336
export type Column<T> = {
@@ -48,6 +51,9 @@ export const DataTable = <T,>({
4851
getRowProps,
4952
rows,
5053
pagination = false,
54+
favoriteKeyPath,
55+
favorites = [],
56+
...rest
5157
}: DataTableProps<T>) => {
5258
const [sortOrder, setSortOrder] = useState<"asc" | "desc" | null>(
5359
defaultSorting?.value || null
@@ -57,9 +63,42 @@ export const DataTable = <T,>({
5763
);
5864

5965
const displayRows = useMemo(() => {
60-
if (currentSortKey && sortOrder) {
66+
if (!rows.length) return [];
67+
68+
const hasFavoriteFeature = !!favoriteKeyPath && favorites.length > 0;
69+
70+
let favoriteRows: typeof rows = [];
71+
let normalRows: typeof rows = [];
72+
73+
if (hasFavoriteFeature) {
74+
favoriteRows = rows.filter((row) => {
75+
const value = favoriteKeyPath
76+
.split(".")
77+
.reduce(
78+
(obj, key) => obj && obj[key as keyof typeof obj],
79+
row.data as any
80+
);
81+
return value !== undefined && favorites.includes(value);
82+
});
83+
84+
normalRows = rows.filter((row) => {
85+
const value = favoriteKeyPath
86+
.split(".")
87+
.reduce(
88+
(obj, key) => obj && obj[key as keyof typeof obj],
89+
row.data as any
90+
);
91+
return value === undefined || !favorites.includes(value);
92+
});
93+
} else {
94+
normalRows = [...rows];
95+
}
96+
97+
const sortRows = (rowsToSort: typeof rows) => {
98+
if (!currentSortKey || !sortOrder) return rowsToSort;
99+
61100
return _.orderBy(
62-
rows,
101+
rowsToSort,
63102
[
64103
(row) => {
65104
const column = columns.find(
@@ -73,9 +112,16 @@ export const DataTable = <T,>({
73112
],
74113
[sortOrder]
75114
);
115+
};
116+
117+
if (hasFavoriteFeature) {
118+
const sortedFavoriteRows = sortRows(favoriteRows);
119+
const sortedNormalRows = sortRows(normalRows);
120+
return [...sortedFavoriteRows, ...sortedNormalRows];
76121
}
77-
return rows;
78-
}, [rows, currentSortKey, sortOrder, columns]);
122+
123+
return sortRows(rows);
124+
}, [rows, currentSortKey, sortOrder, columns, favoriteKeyPath, favorites]);
79125

80126
const handleSort = (column: Column<T>) => {
81127
if (!column.sortKey) return;
@@ -148,11 +194,11 @@ export const DataTable = <T,>({
148194
</Table.Row>
149195
);
150196
},
151-
[columns, renderColumn]
197+
[columns, renderColumn, getRowProps, isInteractive]
152198
);
153199

154200
return (
155-
<Table.ScrollArea maxHeight="4xl">
201+
<Table.ScrollArea maxHeight="4xl" {...rest}>
156202
<Table.Root interactive={isInteractive} showColumnBorder stickyHeader>
157203
<Table.Header>
158204
<Table.Row>
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { IconButton } from "@chakra-ui/react";
2+
import React, { useCallback, useEffect, useState } from "react";
3+
import { IoIosStarOutline } from "react-icons/io";
4+
import { IoStar } from "react-icons/io5";
5+
6+
export type FavoriteValue = string | number;
7+
8+
export const getFavoritesFromStorage = <T extends FavoriteValue>(
9+
key: string
10+
): T[] => {
11+
try {
12+
const storedItems = localStorage.getItem(key);
13+
return storedItems ? JSON.parse(storedItems) : [];
14+
} catch (error) {
15+
console.error(`Error retrieving favorites from storage (${key}):`, error);
16+
return [];
17+
}
18+
};
19+
20+
export const saveFavoritesToStorage = <T extends FavoriteValue>(
21+
key: string,
22+
favorites: T[]
23+
): void => {
24+
try {
25+
localStorage.setItem(key, JSON.stringify(favorites));
26+
} catch (error) {
27+
console.error(`Error saving favorites to storage (${key}):`, error);
28+
}
29+
};
30+
31+
export const toggleFavorite = <T extends FavoriteValue>(
32+
value: T,
33+
currentFavorites: T[],
34+
storageKey?: string
35+
): T[] => {
36+
const newFavorites = currentFavorites.includes(value)
37+
? currentFavorites.filter((item) => item !== value)
38+
: [...currentFavorites, value];
39+
40+
if (storageKey) {
41+
saveFavoritesToStorage(storageKey, newFavorites);
42+
}
43+
44+
return newFavorites;
45+
};
46+
47+
type FavoriteIconProps = {
48+
id: FavoriteValue;
49+
storageKey: string;
50+
onChange?: (favorites: FavoriteValue[]) => void;
51+
externalFavorites?: FavoriteValue[];
52+
};
53+
54+
export const FavoriteIcon: React.FC<FavoriteIconProps> = ({
55+
id,
56+
storageKey,
57+
onChange,
58+
externalFavorites,
59+
}) => {
60+
const [internalFavorites, setInternalFavorites] = useState<FavoriteValue[]>(
61+
() => getFavoritesFromStorage(storageKey)
62+
);
63+
64+
const favorites =
65+
externalFavorites !== undefined ? externalFavorites : internalFavorites;
66+
67+
useEffect(() => {
68+
if (onChange && externalFavorites === undefined) {
69+
onChange(internalFavorites);
70+
}
71+
}, [internalFavorites, onChange, externalFavorites]);
72+
73+
const handleToggle = useCallback(
74+
(e: React.MouseEvent) => {
75+
e.stopPropagation();
76+
77+
const newFavorites = toggleFavorite(id, favorites, storageKey);
78+
79+
if (externalFavorites === undefined) {
80+
setInternalFavorites(newFavorites);
81+
}
82+
83+
if (onChange) {
84+
onChange(newFavorites);
85+
}
86+
},
87+
[id, favorites, storageKey, onChange, externalFavorites]
88+
);
89+
90+
const isFavorite = favorites.includes(id);
91+
92+
return (
93+
<IconButton
94+
aria-label={isFavorite ? "즐겨찾기 해제" : "즐겨찾기 추가"}
95+
onClick={handleToggle}
96+
size="sm"
97+
variant="ghost"
98+
>
99+
{isFavorite ? (
100+
<IoStar color="#FFD700" />
101+
) : (
102+
<IoIosStarOutline color="gray" />
103+
)}
104+
</IconButton>
105+
);
106+
};
107+
108+
export const sortWithFavoritesAtTop = <T,>(
109+
items: T[],
110+
getFavoriteId: (item: T) => FavoriteValue,
111+
favorites: FavoriteValue[]
112+
): T[] => {
113+
if (!items.length || !favorites.length) return items;
114+
115+
const favoriteItems = items.filter((item) =>
116+
favorites.includes(getFavoriteId(item))
117+
);
118+
const regularItems = items.filter(
119+
(item) => !favorites.includes(getFavoriteId(item))
120+
);
121+
122+
return [...favoriteItems, ...regularItems];
123+
};

src/frontend/src/pages/content-wage-list/tabs/content-wage-table-tab/components/content-group-wage-list-table.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Flex, FormatNumber, IconButton, Text } from "@chakra-ui/react";
2+
import { useState } from "react";
23
import { IoIosSettings } from "react-icons/io";
34

45
import { useAuth } from "~/core/auth";
@@ -7,6 +8,11 @@ import { FormatGold } from "~/core/format";
78
import { useSafeQuery } from "~/core/graphql";
89
import { ContentGroupWageListTableDocument } from "~/core/graphql/generated";
910
import { DataTable } from "~/core/table";
11+
import {
12+
FavoriteIcon,
13+
FavoriteValue,
14+
getFavoritesFromStorage,
15+
} from "~/core/table/favorite-control";
1016
import { LoginTooltip } from "~/core/tooltip";
1117
import { useContentWageListPage } from "~/pages/content-wage-list/content-wage-list-page-context";
1218
import {
@@ -15,6 +21,8 @@ import {
1521
} from "~/shared/content";
1622
import { ItemNameWithImage } from "~/shared/item";
1723

24+
const FAVORITE_STORAGE_KEY = "content-group-wage-list-favorites";
25+
1826
export const ContentGroupWageListTable = () => {
1927
const { isAuthenticated } = useAuth();
2028
const {
@@ -26,7 +34,13 @@ export const ContentGroupWageListTable = () => {
2634
shouldMergeGate,
2735
} = useContentWageListPage();
2836

29-
if (!shouldMergeGate) return null;
37+
const [favorites, setFavorites] = useState<FavoriteValue[]>(
38+
getFavoritesFromStorage(FAVORITE_STORAGE_KEY)
39+
);
40+
41+
const handleFavoriteChange = (newFavorites: FavoriteValue[]) => {
42+
setFavorites(newFavorites);
43+
};
3044

3145
const { data, refetch } = useSafeQuery(ContentGroupWageListTableDocument, {
3246
variables: {
@@ -44,10 +58,27 @@ export const ContentGroupWageListTable = () => {
4458
dialog: ContentGroupDetailsDialog,
4559
});
4660

61+
if (!shouldMergeGate || !data) return null;
62+
4763
return (
4864
<>
4965
<DataTable
5066
columns={[
67+
{
68+
align: "center",
69+
header: "즐겨찾기",
70+
render({ data }) {
71+
return (
72+
<FavoriteIcon
73+
externalFavorites={favorites}
74+
id={data.contentGroup.name}
75+
onChange={handleFavoriteChange}
76+
storageKey={FAVORITE_STORAGE_KEY}
77+
/>
78+
);
79+
},
80+
width: 12,
81+
},
5182
{
5283
header: "종류",
5384
render({ data }) {
@@ -132,12 +163,17 @@ export const ContentGroupWageListTable = () => {
132163
sortKey: "goldAmountPerClear",
133164
},
134165
]}
166+
favoriteKeyPath="contentGroup.name"
167+
favorites={favorites}
135168
getRowProps={({ data }) => ({
136169
onClick: () =>
137170
onOpen({
138171
contentIds: data.contentGroup.contentIds,
139172
onComplete: refetch,
140173
}),
174+
style: favorites.includes(data.contentGroup.name)
175+
? { backgroundColor: "rgba(255, 215, 0, 0.075)" }
176+
: undefined,
141177
})}
142178
rows={data.contentGroupWageList.map((data) => ({
143179
data,

0 commit comments

Comments
 (0)