This commit is contained in:
2025-06-23 16:46:09 +09:00
68 changed files with 1289 additions and 250 deletions
@@ -0,0 +1,2 @@
import { AdminPage } from "@/pages/admin";
export default AdminPage;
+5 -5
View File
@@ -13,7 +13,6 @@ export function middleware(req: NextRequest) {
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
queryParams ? `?${queryParams}` : ""
}`;
console.log(`[Middleware] Rewriting from '/server' path to: ${newUrl}`);
return NextResponse.rewrite(new URL(newUrl));
}
@@ -26,17 +25,18 @@ export function middleware(req: NextRequest) {
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
console.log(
`[Middleware] Rewriting from '/attach-service' path to: ${newUrl}`,
);
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname === "/") {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/server/:path*", "/attach-service/:path*"],
matcher: ["/server/:path*", "/attach-service/:path*", "/"],
};
@@ -8,7 +8,7 @@ import type {
PageGroupUserList,
GroupUserSaveData,
GroupUserUpdateData,
} from "../model/group-users-interface";
} from "../model/group-user-interface";
export class GroupUsersApiService implements GroupUserService {
// 그룹 사용자 목록 조회
+5
View File
@@ -0,0 +1,5 @@
export { GroupUsersApi } from "./api/group-user-service";
export {
type GroupUserListResponseDto,
type GroupUserSaveData,
} from "./model/group-user-interface";
@@ -6,7 +6,7 @@ import type {
GroupList,
GroupResponseDto,
GroupFormData,
} from "../model/groups-interface";
} from "../model/group-interface";
export class GroupsApiService implements GroupService {
// 그룹 목록 조회
@@ -56,4 +56,4 @@ export class GroupsApiService implements GroupService {
}
}
export const GroupsApi = new GroupsApiService();
export const GroupApi = new GroupsApiService();
+6
View File
@@ -0,0 +1,6 @@
export { GroupApi } from "./api/group-service";
export {
groupFormSchema,
type GroupFormData,
type GroupListResponseDto,
} from "./model/group-interface";
@@ -1,3 +1,4 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
@@ -27,12 +28,10 @@ type ManagementAuthorityDialogProps = {
menuCd?: number;
} & React.ComponentProps<typeof Dialog>;
function ManagementAuthorityDialog({
menuCd,
...props
}: ManagementAuthorityDialogProps) {
function ManagementAuthorityDialog({ menuCd }: ManagementAuthorityDialogProps) {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const [open, setOpen] = useState(false);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_AUTHRTS, menuCd],
@@ -44,7 +43,7 @@ function ManagementAuthorityDialog({
mutationFn: (data: MenuAuthorityPayload[]) =>
MenuAuthorityApi.saveMenuAuthority(data),
onSuccess: () => {
alert("저장되었습니다.");
alert("저장되었습니다.", () => setOpen(false));
},
});
@@ -72,7 +71,7 @@ function ManagementAuthorityDialog({
};
return (
<Dialog {...props}>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-3" disabled={!menuCd}>
<i className="diveicon di-personal-information" />
@@ -52,6 +52,7 @@ type PostingTarget = {
type PostingDialogProps = {
postingType: PostingType;
postingTargets: PostingTarget[];
resetSelectedPost: () => void;
};
const POSTING_TYPE_LABEL = {
@@ -61,7 +62,11 @@ const POSTING_TYPE_LABEL = {
SURVEY: "설문",
} as const;
function PostingDialog({ postingType, postingTargets }: PostingDialogProps) {
function PostingDialog({
postingType,
postingTargets,
resetSelectedPost,
}: PostingDialogProps) {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
@@ -71,6 +76,7 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) {
const [rememberPeriodDate, setRememberPeriodDate] = useState<
Record<string, DateRange | undefined>
>({});
const [open, setOpen] = useState(false);
const { data: siteList } = useQuery<PageableList<Site>>({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
@@ -80,7 +86,10 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) {
const { mutateAsync: savePosting } = useMutationWrapper({
mutationFn: (data: PostPayload[]) => PostingApi.savePosting(data),
onSuccess: () => {
alert("저장되었습니다.");
alert("저장되었습니다.", () => {
setOpen(false);
resetSelectedPost();
});
},
});
@@ -218,14 +227,19 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) {
};
return (
<Dialog onOpenChange={handleOpenChange}>
<Dialog
open={open}
onOpenChange={(open) => {
setOpen(open);
handleOpenChange(open);
}}
>
<DialogTrigger asChild>
<AuthorityButton
authority="mngAuthrtYn"
color="emerald"
disabled={!postingTargets.length}
>
<i className="diveicon di-layer" />
사이트반영
</AuthorityButton>
</DialogTrigger>
@@ -63,8 +63,8 @@ export const userOrganizationPayloadSchema = z.object({
mode: z.string(),
ognzSn: z.number(),
userUnqId: z.string(),
jbpsSeCd: z.string(),
jbgdSeCd: z.string(),
jbpsSeCd: z.string().min(1),
jbgdSeCd: z.string().min(1),
sortSeq: z.number(),
});
@@ -16,6 +16,7 @@ import type {
UserVerifyData,
UserEmailData,
UserIdData,
AdminUserList,
} from "../model/user-interface";
export class UserApiService implements UserService {
@@ -208,6 +209,17 @@ export class UserApiService implements UserService {
);
return response.data;
}
// 관리자 목록 조회
public async findPageAdminUser(
params: UserSearchParams,
): Promise<AdminUserList> {
const response = await client.get<AdminUserList>(
`${ENDPOINT.USER_SERVICE.USERS}/admin/list`,
{ params },
);
return response.data;
}
}
export const UserApi = new UserApiService();
+3
View File
@@ -10,4 +10,7 @@ export {
type UserDetailResponse,
type UserPasswordUpdateData,
type UserUpdateData,
type AdminUserList,
type AdminUserListResponse,
} from "./model/user-interface";
export { PasswordResetDialog } from "./ui/password-reset-dialog";
@@ -103,6 +103,28 @@ export interface UserSttsStatsResponse {
cnt: number;
}
// 관리자 목록 응답 인터페이스
export interface AdminUserListResponse {
userUnqId: string;
userId: string;
userName: string;
groupId: string;
groupNm: string;
userStateCode: string;
userStateCodeName: string;
laeSeCd: string;
laeSeNm: string;
ogdpSeCd: string;
ogdpSeNm: string;
instSeCd: string;
instSeNm: string;
dtlInstCd: string;
dtlInstNm: string;
mdfr: string;
mdfrNm: string;
mdfcnDt: string;
}
// 사용자 수정 요청 스키마
export const userUpdateSchema = z.object({
userId: z.string().optional(),
@@ -264,6 +286,7 @@ export type UserIdData = z.infer<typeof userIdSchema>;
// 페이지네이션 타입 정의
export type UserList = PageableList<UserListResponse>;
export type UserSttsStatsList = UserSttsStatsResponse[];
export type AdminUserList = PageableList<AdminUserListResponse>;
// 사용자 서비스 인터페이스
export interface UserService {
@@ -332,4 +355,7 @@ export interface UserService {
// 사용자 아이디 중복 체크
existsUserId: (data: UserIdData) => Promise<boolean>;
// 관리자 목록 조회
findPageAdminUser: (params: UserSearchParams) => Promise<AdminUserList>;
}
@@ -31,9 +31,13 @@ import { useAlertStore, useMutationWrapper } from "@/shared/lib";
interface PasswordResetDialogProps {
userUnqId: string;
children: React.ReactNode;
}
export function PasswordResetDialog({ userUnqId }: PasswordResetDialogProps) {
export function PasswordResetDialog({
userUnqId,
children,
}: PasswordResetDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const [open, setOpen] = useState(false);
@@ -51,19 +55,16 @@ export function PasswordResetDialog({ userUnqId }: PasswordResetDialogProps) {
mutationFn: (data: UserPasswordUpdateData) =>
UserApi.initializePassword(userUnqId, data),
onSuccess: () => {
alert("비밀번호가 초기화되었습니다.");
setOpen(false);
form.reset();
alert("비밀번호가 초기화되었습니다.", () => {
setOpen(false);
form.reset();
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button color="gray">
<i className="diveicon di-arrow-reset" /> 비밀번호 초기화
</Button>
</DialogTrigger>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>비밀번호 초기화</DialogTitle>
@@ -0,0 +1,50 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
ManagerIp,
ManagerIpInfo,
ManagerIpList,
ManagerIpParams,
ManagerIpService,
} from "../model/manager-ip-interface";
export class ManagerIpApiService implements ManagerIpService {
public async getManagerIpList(
params: ManagerIpParams,
): Promise<ManagerIpList> {
const response = await client.get<ManagerIpList>(
`${ENDPOINT.CMS_SERVICE.MANAGER_IP}`,
{ params },
);
return response.data;
}
public async getManagerIpInfo(mngNo: string): Promise<ManagerIpInfo> {
const response = await client.get<ManagerIpInfo>(
`${ENDPOINT.CMS_SERVICE.MANAGER_IP}/${mngNo}`,
);
return response.data;
}
public async saveManagerIp(data: ManagerIp[]): Promise<void> {
const response = await client.post<void>(
ENDPOINT.CMS_SERVICE.MANAGER_IP,
data,
);
return response.data;
}
public async updateManagerIp(mngNo: string, data: ManagerIp): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MANAGER_IP}/${mngNo}`,
data,
);
return response.data;
}
public async deleteManagerIp(mngNo: string): Promise<void> {
await client.delete(`${ENDPOINT.CMS_SERVICE.MANAGER_IP}/${mngNo}`);
}
}
export const ManagerIpApi = new ManagerIpApiService();
+1
View File
@@ -0,0 +1 @@
export { AdminPage } from "./ui/admin-page";
@@ -0,0 +1,42 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface ManagerIpParams extends PageableParams {
ipAddr?: string;
userUnqId?: string;
}
export const managerIpSchema = z.object({
mngNo: z.number().optional(),
mode: z.string(),
ipAddr: z.string().max(15),
userUnqId: z.string(),
regDt: z.string().optional(),
rgtr: z.string().optional(),
mdfcnDt: z.string().optional(),
mdfr: z.string().optional(),
});
export const managerIpFormSchema = z.object({
ips: z.array(
managerIpSchema.omit({
regDt: true,
rgtr: true,
mdfcnDt: true,
mdfr: true,
}),
),
});
export type ManagerIp = z.infer<typeof managerIpSchema>;
export type ManagerIpFormData = z.infer<typeof managerIpFormSchema>;
export type ManagerIpList = PageableList<ManagerIp>;
export type ManagerIpInfo = ManagerIp;
export interface ManagerIpService {
getManagerIpList: (params: ManagerIpParams) => Promise<ManagerIpList>;
getManagerIpInfo: (mngNo: string) => Promise<ManagerIpInfo>;
saveManagerIp: (data: ManagerIp[]) => Promise<void>;
updateManagerIp: (mngNo: string, data: ManagerIp) => Promise<void>;
deleteManagerIp: (mngNo: string) => Promise<void>;
}
@@ -0,0 +1,112 @@
import dayjs from "dayjs";
import { memo } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { useQueryClient } from "@tanstack/react-query";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button } from "@common/components";
import { PasswordResetDialog, type AdminUserList } from "@/entities/user";
import { GroupUsersApi, type GroupUserSaveData } from "@/entities/group-user";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { ManagerIpDialog } from "../ui/manager-ip-dialog";
export const columns: ColumnDef<AdminUserList["content"][number]>[] = [
{
id: "no",
header: "NO",
cell: ({ row, table }) => {
const totalRows = table.getRowCount();
const currentPage = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return totalRows - (currentPage * pageSize + row.index);
},
},
{
accessorKey: "userId",
header: "관리자ID",
},
{
accessorKey: "userName",
header: "관리자명",
},
{
accessorKey: "groupNm",
header: "관리자구분",
},
{
accessorKey: "ogdpSeNm",
header: "소속",
},
{
accessorKey: "mdfrNm",
header: "최종수정자",
},
{
accessorKey: "mdfcnDt",
header: "최종수정일",
cell: ({ row }) => dayjs(row.original.mdfcnDt).format("YYYY-MM-DD"),
},
{
id: "action",
header: "관리",
cell: ({ row }) => {
return (
<ActionCell
userUnqId={row.original.userUnqId}
groupId={row.original.groupId}
/>
);
},
},
];
const ActionCell = memo(
({ userUnqId, groupId }: { userUnqId: string; groupId: string }) => {
const queryClient = useQueryClient();
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const { mutate: onDelete } = useMutationWrapper({
mutationFn: (data: GroupUserSaveData) =>
GroupUsersApi.saveGroupUser([data]),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
});
alert("삭제되었습니다.");
},
});
const handleDelete = async () => {
if (await confirm("관리자를 삭제하시겠습니까?")) {
onDelete({
mode: "D",
groupId,
userUnqId,
});
}
};
return (
<div className="flex justify-center gap-2">
<ManagerIpDialog userUnqId={userUnqId} />
<PasswordResetDialog userUnqId={userUnqId}>
<Button size="sm" color="gray">
비밀번호 초기화
</Button>
</PasswordResetDialog>
<Button size="sm" color="red" onClick={handleDelete}>
삭제
</Button>
</div>
);
},
(prevProps, nextProps) => {
return (
prevProps.userUnqId === nextProps.userUnqId &&
prevProps.groupId === nextProps.groupId
);
},
);
ActionCell.displayName = "ActionCell";
@@ -0,0 +1,258 @@
"use client";
import { useState } from "react";
import {
keepPreviousData,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
getCoreRowModel,
type PaginationState,
type RowSelectionState,
type ColumnDef,
} from "@tanstack/react-table";
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
Input,
Checkbox,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Label,
} from "@common/components";
import {
UserApi,
type UserListResponse,
type UserSearchParams,
} from "@/entities/user";
import { GroupApi } from "@/entities/group";
import { GroupUsersApi } from "@/entities/group-user";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable } from "@/shared/lib";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
function AddAdminDialog() {
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
const { confirm } = useConfirm();
const [open, setOpen] = useState(false);
const [userSearchQuery, setUserSearchQuery] = useState<UserSearchParams>({
keywordType: "",
keyword: "",
});
const [selectedGroupId, setSelectedGroupId] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { data: groupOptions } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.GROUPS],
queryFn: () =>
GroupApi.findPageGroup({
delYn: "N",
mngrYn: "Y",
page: 0,
size: 99,
}),
select: (data) =>
data.content.map((item) => ({
groupId: item.groupId,
groupNm: item.groupNm,
})),
enabled: open,
});
const query = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS, pagination, "normal-user"],
queryFn: () =>
UserApi.findPageUser({
...userSearchQuery,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
enabled: open,
placeholderData: keepPreviousData,
});
const columns: ColumnDef<UserListResponse>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="전체 선택"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="행 선택"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "userId",
header: "사용자ID",
},
{
accessorKey: "userName",
header: "이름",
},
{
accessorKey: "ogdpSeNm",
header: "소속",
},
{
accessorKey: "mphn",
header: "연락처",
},
];
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns: columns,
state: { pagination, rowSelection },
manualPagination: true,
enableRowSelection: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getRowId: (row) => row.userUnqId,
},
dataTableOptions: {
viewPagination: true,
},
});
const handleAddAdmins = async () => {
const selectedRows = Object.keys(rowSelection);
if (!(await confirm("관리자를 추가하시겠습니까?"))) {
return;
}
try {
const usersToAdd = selectedRows.map((userUnqId) => ({
mode: "C",
groupId: selectedGroupId,
userUnqId,
}));
await GroupUsersApi.saveGroupUser(usersToAdd);
queryClient.invalidateQueries({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
});
alert("관리자 추가를 완료했습니다.", () => {
setOpen(false);
setRowSelection({});
setSelectedGroupId("");
});
} catch (error) {
console.error(error);
}
};
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
setPagination({ pageIndex: 0, pageSize: 10 });
query.refetch();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>관리자등록</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>관리자 등록</DialogTitle>
</DialogHeader>
<form onSubmit={handleSearch} className="flex items-center gap-2">
<Select
onValueChange={(value) =>
setUserSearchQuery({ ...userSearchQuery, keywordType: value })
}
>
<SelectTrigger className="h-[3.2rem] w-[12rem] !text-[1.3rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="userId">아이디</SelectItem>
<SelectItem value="userName">이름</SelectItem>
</SelectContent>
</Select>
<Input
value={userSearchQuery.keyword}
onChange={(e) =>
setUserSearchQuery({
...userSearchQuery,
keyword: e.target.value,
})
}
className="!text-[1.3rem]"
placeholder="검색어 입력"
/>
<Button type="submit">검색</Button>
</form>
<hr className="my-4" />
<Label className="text-label">1. 사용자 선택</Label>
{tableComponent}
<hr className="my-4" />
<div className="mb-4 flex flex-col gap-4">
<Label className="mr-2 text-label">2. 그룹 선택</Label>
<Select onValueChange={(value) => setSelectedGroupId(value)}>
<SelectTrigger className="h-[3.2rem] w-[24rem] !text-[1.3rem]">
<SelectValue placeholder="그룹 선택" />
</SelectTrigger>
<SelectContent>
{groupOptions?.map((item) => (
<SelectItem key={item.groupId} value={item.groupId}>
{item.groupNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button color="white" onClick={() => setOpen(false)}>
취소
</Button>
<Button
onClick={handleAddAdmins}
disabled={
Object.keys(rowSelection).length === 0 || !selectedGroupId
}
>
등록
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { AddAdminDialog };
+236
View File
@@ -0,0 +1,236 @@
"use client";
import { useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCoreRowModel,
type PaginationState,
type RowSelectionState,
type Table,
type Row,
} from "@tanstack/react-table";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Checkbox,
} from "@common/components";
import { GroupApi } from "@/entities/group";
import {
UserApi,
type UserSearchParams,
type AdminUserListResponse,
} from "@/entities/user";
import { GroupUsersApi } from "@/entities/group-user";
import { ENDPOINT } from "@/shared/config";
import { useDataTable, useAlertStore } from "@/shared/lib";
import { columns } from "../model/table-columns";
import { AddAdminDialog } from "./add-admin-dialog";
function AdminPage() {
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
const { confirm } = useConfirm();
const [adminQuery, setAdminQuery] = useState<UserSearchParams>({
keywordType: "",
keyword: "",
grpId: "",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { data: groupOptions } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.GROUPS],
queryFn: () =>
GroupApi.findPageGroup({
delYn: "N",
mngrYn: "Y",
page: 0,
size: 99,
}),
select: (data) =>
data.content.map((item) => ({
groupId: item.groupId,
groupNm: item.groupNm,
})),
});
const query = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS, pagination],
queryFn: () =>
UserApi.findPageAdminUser({
...adminQuery,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
});
const adminColumns = [
{
id: "select",
header: ({ table }: { table: Table<AdminUserListResponse> }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="전체 선택"
/>
),
cell: ({ row }: { row: Row<AdminUserListResponse> }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="행 선택"
/>
),
enableSorting: false,
enableHiding: false,
},
...columns,
];
const { tableComponent, tableInstance: table } = useDataTable({
query,
tableOptions: {
columns: adminColumns,
state: {
pagination,
rowSelection,
},
manualPagination: true,
enableRowSelection: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
viewPagination: true,
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
query.refetch();
};
const handleDeleteAdmins = async () => {
const selectedRows = table.getSelectedRowModel().rows.map((row) => ({
groupId: row.original.groupId,
userUnqId: row.original.userUnqId,
}));
if (!(await confirm("관리자를 삭제하시겠습니까?"))) {
return;
}
try {
const usersToDelete = selectedRows.map((row) => ({
mode: "D",
groupId: row.groupId,
userUnqId: row.userUnqId,
}));
await GroupUsersApi.saveGroupUser(usersToDelete);
queryClient.invalidateQueries({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
});
alert("관리자 삭제를 완료했습니다.");
setRowSelection({});
} catch (error) {
console.error(error);
}
};
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-6 p-2">
<div className="flex items-center gap-2">
<Label className="mr-2 text-label">관리자구분</Label>
<Select
onValueChange={(value) =>
setAdminQuery({ ...adminQuery, grpId: value })
}
>
<SelectTrigger className="h-[3.2rem] w-[16rem] !text-[1.3rem]">
<SelectValue placeholder="그룹 선택" />
</SelectTrigger>
<SelectContent>
{groupOptions?.map((item) => (
<SelectItem key={item.groupId} value={item.groupId}>
{item.groupNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="" className="mr-2 text-label">
관리자
</Label>
<Select
onValueChange={(value) =>
setAdminQuery({ ...adminQuery, keywordType: value })
}
>
<SelectTrigger className="h-[3.2rem] w-[10rem] !text-[1.3rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="userId">아이디</SelectItem>
<SelectItem value="userName">이름</SelectItem>
</SelectContent>
</Select>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={adminQuery.keyword}
onChange={(e) =>
setAdminQuery({ ...adminQuery, keyword: e.target.value })
}
placeholder="검색어 입력"
/>
</div>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<div className="flex w-full items-center justify-between">
<h3 className="text-2xl font-medium text-label">
관리자 목록(총 {query.data?.totalElements}명)
</h3>
<div className="flex gap-2">
<AddAdminDialog />
<Button
color="red"
onClick={handleDeleteAdmins}
disabled={Object.keys(rowSelection).length === 0}
>
선택삭제
</Button>
</div>
</div>
</div>
<div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</div>
</div>
);
}
export { AdminPage };
@@ -0,0 +1,241 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useForm, useFieldArray } from "react-hook-form";
import {
Dialog,
DialogTrigger,
Button,
DialogContent,
DialogHeader,
DialogTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Input,
DialogFooter,
DialogClose,
Checkbox,
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
import { ManagerIpApi } from "../api/manager-ip-service";
import {
managerIpFormSchema,
type ManagerIp,
type ManagerIpFormData,
} from "../model/manager-ip-interface";
type ManagerIpDialogProps = {
userUnqId: string;
};
function ManagerIpDialog({ userUnqId }: ManagerIpDialogProps) {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const [open, setOpen] = useState(false);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const { data: managerIpList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MANAGER_IP, open],
queryFn: () => ManagerIpApi.getManagerIpList({ userUnqId }),
select: (data) => {
const ips = data.content.map((item) => ({
...item,
mode: "U",
}));
initialForm();
return ips;
},
enabled: open,
});
const { mutateAsync } = useMutationWrapper({
mutationFn: (data: ManagerIp[]) => ManagerIpApi.saveManagerIp(data),
});
const form = useForm<ManagerIpFormData>({
resolver: zodResolver(managerIpFormSchema),
values: {
ips: managerIpList ?? [],
},
});
const { fields, append, remove, update } = useFieldArray({
control: form.control,
name: "ips",
});
const initialForm = () => {
form.reset();
setSelectedRows([]);
};
const onSubmit = async (data: ManagerIpFormData) => {
if (await confirm("저장하시겠습니까?")) {
await mutateAsync(data.ips);
alert("저장되었습니다.", () => {
setOpen(false);
});
}
};
const handleAddIP = () => {
append({
mngNo: 0,
mode: "C",
ipAddr: "",
userUnqId,
});
};
const handleSelectAll = () => {
if (selectedRows.length === fields.length) {
setSelectedRows([]);
} else {
setSelectedRows(fields.map((_, index) => index));
}
};
const handleSelectRow = (i: number) => {
setSelectedRows((prev) =>
prev.includes(i) ? prev.filter((j) => j !== i) : [...prev, i],
);
};
const handleDeleteIP = async () => {
if (selectedRows.length === 0) return;
if (await confirm("선택한 IP를 삭제하시겠습니까?")) {
const sortedIndices = [...selectedRows].sort((a, b) => b - a);
for (const i of sortedIndices) {
const field = fields[i];
if (field?.mngNo) {
if (field.mode === "C") {
remove(i);
} else {
update(i, {
...field,
mode: "D",
});
}
} else {
remove(i);
}
}
setSelectedRows([]);
}
};
const isAllSelected =
fields.length > 0 && selectedRows.length === fields.length;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">접속 IP</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>접속 IP 관리</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
>
<div className="mb-4 flex justify-end gap-2">
<Button type="button" size="sm" onClick={handleAddIP}>
항목 추가
</Button>
<Button
type="button"
size="sm"
color="red"
onClick={handleDeleteIP}
disabled={selectedRows.length === 0}
>
항목 삭제
</Button>
</div>
<div className="card card-border max-h-[28.5rem] overflow-auto">
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="90%" />
</colgroup>
<TableHeader className="sticky -top-[0.6rem]">
<TableRow>
<TableHead className="!px-0">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>IP 주소</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, i) => {
if (field.mode === "D") return null;
return (
<TableRow key={field.id}>
<TableCell align="center" className="!px-0">
<Checkbox
checked={selectedRows.includes(i)}
onCheckedChange={() => handleSelectRow(i)}
/>
</TableCell>
<TableCell align="center">
<FormField
control={form.control}
name={`ips.${i}.ipAddr`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
className="!text-[1.3rem]"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<DialogFooter className="!justify-center">
<Button type="submit" size="lg">
저장
</Button>
<DialogClose asChild>
<Button type="button" size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
export { ManagerIpDialog };
+2 -6
View File
@@ -35,14 +35,13 @@ function BannerPage() {
});
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.BANNERS],
queryKey: [ENDPOINT.CMS_SERVICE.BANNERS, pagination],
queryFn: () =>
BannerApi.getBannerList({
...bannerQuery,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
enabled: false,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
@@ -99,10 +98,6 @@ function BannerPage() {
setPostingTargets(postingTargets);
}, [rowSelection]);
useEffect(() => {
query.refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
@@ -131,6 +126,7 @@ function BannerPage() {
<PostingDialog
postingType={POSTING_TYPE.BANNER}
postingTargets={postingTargets}
resetSelectedPost={() => table.setRowSelection({})}
/>
<Button onClick={() => router.push("/banner/register")}>
등록
+1 -1
View File
@@ -42,7 +42,7 @@ function ModifyPage({ sequential }: { sequential: string }) {
mutationFn: (data: BannerFormData) =>
BannerApi.updateBanner(sequential, data),
onSuccess: () => {
alert("수정되었습니다.");
alert("수정되었습니다.", router.back);
},
});
+2 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
@@ -39,14 +39,13 @@ function BbsPage() {
});
const query = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.BBS],
queryKey: [ENDPOINT.BOARD_SERVICE.BBS, pagination],
queryFn: () =>
BoardApi.getBoardList({
...boardParams,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
enabled: false,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
@@ -78,10 +77,6 @@ function BbsPage() {
},
});
useEffect(() => {
query.refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
+3 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
@@ -13,6 +14,7 @@ import {
import { BbsForm } from "./bbs-form";
function ModifyPage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const alert = useAlertStore((state) => state.setMessage);
const { data: boardInfo } = useQuery({
@@ -23,7 +25,7 @@ function ModifyPage({ bbsId }: { bbsId: string }) {
const { mutateAsync: updateBoard } = useMutationWrapper({
mutationFn: (data: BoardFormData) => BoardApi.updateBoard(bbsId, data),
onSuccess: () => {
alert("수정되었습니다.");
alert("수정되었습니다.", router.back);
},
});
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
@@ -26,7 +26,7 @@ function ContentsPage() {
});
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS],
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS, pagination],
queryFn: () =>
ContentsApi.getContentsList({
...contentsQuery,
@@ -34,7 +34,6 @@ function ContentsPage() {
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
enabled: false,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
@@ -64,10 +63,6 @@ function ContentsPage() {
},
});
useEffect(() => {
query.refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
+5 -10
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
@@ -15,7 +15,7 @@ function FilePage() {
const alert = useAlertStore((state) => state.setMessage);
const [attachmentQuery, setAttachmentQuery] = useState<AttachmentParams>({
keyword: "",
orgnlFileNm: "",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
@@ -23,7 +23,7 @@ function FilePage() {
});
const query = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS],
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, pagination],
queryFn: () =>
AttachmentApi.getAttachmentsList({
...attachmentQuery,
@@ -31,7 +31,6 @@ function FilePage() {
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
enabled: false,
});
const { mutate: deleteAttachment } = useMutationWrapper({
@@ -75,10 +74,6 @@ function FilePage() {
query.refetch();
};
useEffect(() => {
query.refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
@@ -91,9 +86,9 @@ function FilePage() {
<Input
id="keyword"
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={attachmentQuery.keyword || ""}
value={attachmentQuery.orgnlFileNm || ""}
onChange={(e) =>
handleAttachmentQueryChange("keyword")(e.target.value)
handleAttachmentQueryChange("orgnlFileNm")(e.target.value)
}
placeholder="파일명을 입력하세요"
/>
@@ -1,6 +1,6 @@
import { Checkbox } from "@common/components";
import { type Table, type Row, type ColumnDef } from "@tanstack/react-table";
import { type GroupUserListResponseDto } from "./group-users-interface";
import { type GroupUserListResponseDto } from "@/entities/group-user";
export const columns: ColumnDef<GroupUserListResponseDto>[] = [
{
@@ -1,6 +1,6 @@
import { type ColumnDef } from "@tanstack/react-table";
import { Button } from "@common/components";
import { type GroupListResponseDto } from "./groups-interface";
import { type GroupListResponseDto } from "@/entities/group";
import { MenuAuthorityDialog } from "../ui/menu-authority-dialog";
import { GroupsDialog } from "../ui/groups-dialog";
@@ -28,15 +28,15 @@ import {
type UserListResponse,
type UserSearchParams,
} from "@/entities/user";
import { GroupUsersApi } from "@/entities/group-user";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable } from "@/shared/lib";
import { GroupUsersApi } from "../api/group-users-service";
type AdditionalUserDialogProps = {
groupId?: string;
};
function AdditionalUserDialog({ groupId }: AdditionalUserDialogProps) {
function AddUserDialog({ groupId }: AdditionalUserDialogProps) {
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
@@ -188,4 +188,4 @@ function AdditionalUserDialog({ groupId }: AdditionalUserDialogProps) {
);
}
export { AdditionalUserDialog };
export { AddUserDialog };
+1 -1
View File
@@ -21,8 +21,8 @@ import {
} from "@/shared/ui/form";
import { CodeSelect } from "@/entities/code";
import { SiteSelect } from "@/entities/site";
import type { GroupFormData } from "@/entities/group";
import { RoleSelect } from "./role-select";
import type { GroupFormData } from "../model/groups-interface";
type GroupFormProps = {
form: UseFormReturn<GroupFormData>;
@@ -11,12 +11,13 @@ import {
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button } from "@common/components";
import { GroupUsersApi } from "@/entities/group-user";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable } from "@/shared/lib";
import { columns } from "../model/group-users-table-columns";
import { GroupUsersApi } from "../api/group-users-service";
import { AdditionalUserDialog } from "./additional-user-dialog";
import { AddUserDialog } from "./add-user-dialog";
type GroupUsersSectionProps = {
groupId?: string;
@@ -24,6 +25,7 @@ type GroupUsersSectionProps = {
function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
const queryClient = useQueryClient();
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const [pagination, setPagination] = useState<PaginationState>({
@@ -65,6 +67,7 @@ function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
const handleDeleteSelected = async () => {
const selectedRows = Object.keys(rowSelection);
if (selectedRows.length === 0 || !groupId) return;
if (!(await confirm("사용자를 삭제하시겠습니까?"))) return;
try {
const usersToDelete = selectedRows.map((userUnqId) => ({
@@ -95,7 +98,7 @@ function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
<div className="card-header">
<h3 className="text-2xl font-medium text-label">그룹 사용자 목록</h3>
<div className="flex gap-2">
<AdditionalUserDialog groupId={groupId} />
<AddUserDialog groupId={groupId} />
<Button
color="red"
disabled={table.getSelectedRowModel().rows.length === 0}
+10 -7
View File
@@ -15,12 +15,15 @@ import {
DialogTrigger,
DialogClose,
} from "@common/components";
import { Form } from "@/shared/ui/form";
import { ENDPOINT } from "@/shared/config";
import {
GroupApi,
groupFormSchema,
type GroupFormData,
} from "@/entities/group";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { Form } from "@/shared/ui/form";
import { GroupForm } from "./group-form";
import { GroupsApi } from "../api/groups-service";
import { groupFormSchema, type GroupFormData } from "../model/groups-interface";
type GroupsDialogProps = {
groupId?: string;
@@ -39,12 +42,12 @@ const GroupsDialog = memo(
const { data: group } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.GROUPS, groupId],
queryFn: () => GroupsApi.findGroupById(groupId!),
queryFn: () => GroupApi.findGroupById(groupId!),
enabled: !!groupId,
});
const { mutateAsync: createGroup } = useMutationWrapper({
mutationFn: (data: GroupFormData) => GroupsApi.saveGroup(data),
mutationFn: (data: GroupFormData) => GroupApi.saveGroup(data),
onSuccess: () => {
alert("등록되었습니다.", () => {
setOpen(false);
@@ -55,7 +58,7 @@ const GroupsDialog = memo(
const { mutateAsync: updateGroup } = useMutationWrapper({
mutationFn: (data: GroupFormData) =>
GroupsApi.updateGroup(groupId ?? "", data),
GroupApi.updateGroup(groupId ?? "", data),
onSuccess: () => {
alert("수정되었습니다.", () => {
setOpen(false);
@@ -4,10 +4,10 @@ import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button } from "@common/components";
import { GroupApi } from "@/entities/group";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { columns } from "../model/groups-table-columns";
import { GroupsApi } from "../api/groups-service";
import { GroupsDialog } from "./groups-dialog";
type GroupsSectionProps = {
@@ -23,7 +23,7 @@ function GroupsSection({ handleSelectedGroup }: GroupsSectionProps) {
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.GROUPS, pagination],
queryFn: () =>
GroupsApi.findPageGroup({
GroupApi.findPageGroup({
page: pagination.pageIndex,
size: pagination.pageSize,
}),
@@ -43,7 +43,7 @@ function MenuAuthorityDialog({ groupId }: MenuAuthorityDialogProps) {
mutationFn: (data: MenuAuthorityPayload[]) =>
MenuAuthorityApi.saveMenuAuthority(data),
onSuccess: () => {
alert("저장되었습니다.");
alert("저장되었습니다.", () => setOpen(false));
},
});
+2 -6
View File
@@ -33,14 +33,13 @@ function ImagePage() {
});
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES],
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES, pagination],
queryFn: () =>
ImageApi.getImageList({
...imageQuery,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
enabled: false,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
@@ -88,10 +87,6 @@ function ImagePage() {
setPostingTargets(postingTargets);
}, [rowSelection]);
useEffect(() => {
query.refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
@@ -119,6 +114,7 @@ function ImagePage() {
<PostingDialog
postingType={POSTING_TYPE.IMAGE}
postingTargets={postingTargets}
resetSelectedPost={() => table.setRowSelection({})}
/>
<Button onClick={() => router.push("/image/register")}>
등록
+1 -1
View File
@@ -38,7 +38,7 @@ function ModifyPage({ sequential }: { sequential: string }) {
const { mutateAsync: updateImage } = useMutationWrapper({
mutationFn: (data: ImageFormData) => ImageApi.updateImage(sequential, data),
onSuccess: () => {
alert("수정되었습니다.");
alert("수정되었습니다.", router.back);
},
});
@@ -37,25 +37,25 @@ type ManagementAuthorityDialogProps = {
} & React.ComponentProps<typeof Dialog>;
const ManagementAuthorityDialog = memo(
({ authorizationNo, rowData, ...props }: ManagementAuthorityDialogProps) => {
({ authorizationNo, rowData }: ManagementAuthorityDialogProps) => {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const [isOpened, setIsOpened] = useState(false);
const [open, setOpen] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const query = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.ROLE_AUTHORIZATIONS, authorizationNo],
queryFn: () =>
RoleAuthorizationsApi.getRoleAuthorizationsList({ authorizationNo }),
enabled: !!authorizationNo && isOpened,
enabled: !!authorizationNo && open,
});
const { mutateAsync: saveRoleAuthorization } = useMutationWrapper({
mutationFn: (data: RoleAuthorizationsData) =>
RoleAuthorizationsApi.createRoleAuthorizations(data),
onSuccess: () => {
alert("저장되었습니다.");
alert("저장되었습니다.", () => setOpen(false));
},
});
@@ -91,11 +91,9 @@ const ManagementAuthorityDialog = memo(
};
return (
<Dialog {...props}>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" onClick={() => setIsOpened(true)}>
관리
</Button>
<Button size="sm">관리</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
+3 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
@@ -13,6 +14,7 @@ import {
import { ModuleForm } from "./module-form";
function ModifyPage({ sequential }: { sequential: string }) {
const router = useRouter();
const alert = useAlertStore((state) => state.setMessage);
const { data: authorizationInfo } = useQuery({
@@ -24,7 +26,7 @@ function ModifyPage({ sequential }: { sequential: string }) {
mutationFn: (data: AuthorizationFormData) =>
AuthorizationsApi.updateAuthorization(sequential, data),
onSuccess: () => {
alert("수정되었습니다.");
alert("수정되었습니다.", router.back);
},
});
+2 -7
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
@@ -29,7 +29,7 @@ function ModulePage() {
});
const query = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.AUTHORIZATIONS],
queryKey: [ENDPOINT.USER_SERVICE.AUTHORIZATIONS, pagination],
queryFn: () =>
AuthorizationsApi.getAuthorizationsList({
...authorizationQuery,
@@ -37,7 +37,6 @@ function ModulePage() {
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
enabled: false,
});
const handleRowClick = (row: Authorizations) => {
@@ -71,10 +70,6 @@ function ModulePage() {
query.refetch();
};
useEffect(() => {
query.refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
@@ -18,16 +18,13 @@ import {
} from "@/entities/user-organization";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { columns } from "../model/additional-user-table-columns";
import { columns } from "../model/add-user-table-columns";
type AdditionalUserDialogProps = {
type AddUserDialogProps = {
onAddUsers: (user: SearchUser[]) => void;
} & React.ComponentProps<typeof Dialog>;
function AdditionalUserDialog({
onAddUsers,
...props
}: AdditionalUserDialogProps) {
function AddUserDialog({ onAddUsers, ...props }: AddUserDialogProps) {
const [open, setOpen] = useState(false);
const [userNm, setUserNm] = useState("");
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
@@ -109,4 +106,4 @@ function AdditionalUserDialog({
);
}
export { AdditionalUserDialog };
export { AddUserDialog };
@@ -37,7 +37,7 @@ import { CodeApi, type ICode } from "@/entities/code";
import type { OrganizationTree } from "@/entities/organization";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { AdditionalUserDialog } from "./additional-user-dialog";
import { AddUserDialog } from "./add-user-dialog";
import { MoveOrganizationDialog } from "./move-organization-dialog";
type UserOrganizationSectionProps = {
@@ -114,6 +114,12 @@ function UserOrganizationSection({ ognzSn }: UserOrganizationSectionProps) {
control: form.control,
});
const formData = form.watch("userOrganization");
const formValidation = userOrganizationFormSchema.safeParse({
userOrganization: formData,
});
const isFormValid = formValidation.success;
const handleAddUsers = (users: SearchUser[]) => {
const generateUserData = (user: SearchUser) => ({
mode: "C",
@@ -220,7 +226,7 @@ function UserOrganizationSection({ ognzSn }: UserOrganizationSectionProps) {
}
handleMoveOrganization={handleMoveOrganization}
/>
<AdditionalUserDialog onAddUsers={handleAddUsers} />
<AddUserDialog onAddUsers={handleAddUsers} />
<Button
size="sm"
color="red"
@@ -229,7 +235,7 @@ function UserOrganizationSection({ ognzSn }: UserOrganizationSectionProps) {
>
선택삭제
</Button>
<Button size="sm" onClick={handleSaveUsers}>
<Button size="sm" onClick={handleSaveUsers} disabled={!isFormValid}>
저장
</Button>
</div>
+2 -1
View File
@@ -36,7 +36,7 @@ function PopupPage() {
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.POPUPS],
queryKey: [ENDPOINT.CMS_SERVICE.POPUPS, pagination],
queryFn: () =>
PopupApi.getPopupList({
...popupParams,
@@ -116,6 +116,7 @@ function PopupPage() {
<PostingDialog
postingType={POSTING_TYPE.POPUP}
postingTargets={postingTargets}
resetSelectedPost={() => table.setRowSelection({})}
/>
<AuthorityButton
authority="regAuthrtYn"
+1 -1
View File
@@ -43,7 +43,7 @@ function ViewPopup({ popupSn }: { popupSn: string }) {
}, [popupSn]);
return (
<div className="h-svh bg-container p-4">
<div className="bg-container p-4">
<div className="card card-border !m-0">
<div className="card-header">
<h1 className="text-3xl font-bold text-foreground">
@@ -1,4 +1,5 @@
import React, { useRef } from "react";
import React, { useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Button,
Dialog,
@@ -11,22 +12,41 @@ import {
SelectValue,
} from "@common/components";
import type { Board } from "@/entities/board";
import type { MovePost } from "@/entities/post";
import { PostApi, type MovePost } from "@/entities/post";
import { AuthorityButton } from "@/entities/menu-authority";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
type MovePostDialogProps = {
selectedPosts: MovePost[];
boardList: Board[];
onMovePost: (data: MovePost[]) => void;
resetSelectedPost: () => void;
};
function MovePostDialog({
selectedPosts,
boardList,
onMovePost,
resetSelectedPost,
}: MovePostDialogProps) {
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
const selectedPostsRef = useRef<MovePost[]>(selectedPosts);
const [open, setOpen] = useState(false);
const { mutate: movePost } = useMutationWrapper({
mutationFn: (data: MovePost[]) => PostApi.movePost(data),
onSuccess: () => {
alert("이동이 완료되었습니다.", () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS],
});
resetSelectedPost();
setOpen(false);
});
},
});
const onSelectValueChange = (toBbsId: string) => {
selectedPostsRef.current = selectedPosts.map((post) => ({
...post,
@@ -35,7 +55,7 @@ function MovePostDialog({
};
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<AuthorityButton
authority="mngAuthrtYn"
@@ -61,9 +81,7 @@ function MovePostDialog({
</SelectContent>
</Select>
<Button onClick={() => onMovePost(selectedPostsRef.current)}>
이동
</Button>
<Button onClick={() => movePost(selectedPostsRef.current)}>이동</Button>
</DialogContent>
</Dialog>
);
+22 -23
View File
@@ -24,18 +24,12 @@ import {
import {
PostApi,
type DeletePost,
type MovePost,
type RecentPost,
type RecentPostParams,
} from "@/entities/post";
import { BoardApi } from "@/entities/board";
import { AuthorityButton } from "@/entities/menu-authority";
import {
useAlertStore,
useMutationWrapper,
usePrompt,
useDataTable,
} from "@/shared/lib";
import { useAlertStore, usePrompt, useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { columns } from "../model/table-columns";
import { MovePostDialog } from "./move-post-dialog";
@@ -56,7 +50,7 @@ function RecentPage() {
pageSize: 10,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [recentPostParams, setRecentPostParams] = useState<RecentPostParams>({
const [recentPostQuery, setRecentPostQuery] = useState<RecentPostParams>({
keywordType: "",
keyword: "",
bbsId: null,
@@ -70,7 +64,7 @@ function RecentPage() {
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, pagination],
queryFn: () =>
PostApi.getRecentPostList({
...recentPostParams,
...recentPostQuery,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
@@ -82,13 +76,6 @@ function RecentPage() {
queryFn: () => BoardApi.getBoardList(),
});
const { mutate: movePost } = useMutationWrapper({
mutationFn: (data: MovePost[]) => PostApi.movePost(data),
onSuccess: () => {
alert("이동이 완료되었습니다.", query.refetch);
},
});
const handleRowClick = (row: RecentPost) => {
router.push(
`/bbs/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}`,
@@ -119,13 +106,13 @@ function RecentPage() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsDeletePostData(recentPostParams.delYn === "Y");
setIsDeletePostData(recentPostQuery.delYn === "Y");
query.refetch();
};
const handleRecentPostParamsChange =
(key: keyof RecentPostParams) => (value: unknown) => {
setRecentPostParams({ ...recentPostParams, [key]: value });
setRecentPostQuery({ ...recentPostQuery, [key]: value });
};
const handlePresetDateRange = (value: string) => {
@@ -152,7 +139,16 @@ function RecentPage() {
default:
presetDateRange = undefined;
}
setDateRange(presetDateRange);
handleDateRangeChange(presetDateRange);
};
const handleDateRangeChange = (value: DateRange | undefined) => {
setDateRange(value);
setRecentPostQuery({
...recentPostQuery,
frDateYmd: dayjs(value?.from).format("YYYYMMDD") ?? "",
toDateYmd: dayjs(value?.to).format("YYYYMMDD") ?? "",
});
};
const onSoftDeletePost = async () => {
@@ -209,7 +205,10 @@ function RecentPage() {
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label className="mr-2">날짜</Label>
<DateRangePicker value={dateRange} onValueChange={setDateRange}>
<DateRangePicker
value={dateRange}
onValueChange={handleDateRangeChange}
>
<div className="card card-border flex items-center gap-2">
<Select onValueChange={handlePresetDateRange}>
<SelectTrigger>
@@ -251,7 +250,7 @@ function RecentPage() {
<Label className="ml-4 mr-2 text-label">검색</Label>
<Select
value={recentPostParams.keywordType}
value={recentPostQuery.keywordType}
onValueChange={handleRecentPostParamsChange("keywordType")}
>
<SelectTrigger className="h-[3.2rem] w-[10rem]">
@@ -264,7 +263,7 @@ function RecentPage() {
</SelectContent>
</Select>
<Input
value={recentPostParams.keyword}
value={recentPostQuery.keyword}
onChange={(e) =>
handleRecentPostParamsChange("keyword")(e.target.value)
}
@@ -308,7 +307,7 @@ function RecentPage() {
pstNo: row.original.pstNo,
}))}
boardList={boardList?.content ?? []}
onMovePost={movePost}
resetSelectedPost={() => table.setRowSelection({})}
/>
<AuthorityButton
authority="delAuthrtYn"
@@ -101,7 +101,7 @@ function ScheduleForm<T extends ScheduleFormData | ScheduleUpdateFormData>({
? dayjs(field.value as string).toDate()
: undefined
}
classes={{ trigger: "w-auto px-4 justify-start" }}
classes={{ trigger: "!w-auto px-4 justify-start" }}
onDateChange={handleDateTimeChange(
"schdlBgngDt" as Path<T>,
)}
@@ -120,7 +120,7 @@ function ScheduleForm<T extends ScheduleFormData | ScheduleUpdateFormData>({
? dayjs(field.value as string).toDate()
: undefined
}
classes={{ trigger: "w-auto px-4 justify-start" }}
classes={{ trigger: "!w-auto px-4 justify-start" }}
onDateChange={handleDateTimeChange(
"schdlEndDt" as Path<T>,
)}
@@ -230,7 +230,7 @@ function ScheduleForm<T extends ScheduleFormData | ScheduleUpdateFormData>({
) as PathValue<T, Path<T>>,
);
}}
className="w-auto px-4"
className="!w-auto px-4"
placeholder="반복 종료일 선택"
disabled={form.watch("reptYn" as Path<T>) === "N"}
/>
@@ -89,7 +89,7 @@ function DomainManagementDialog({
if (await confirm("저장하시겠습니까?")) {
await mutateAsync(data.domains);
handleSaveDomain?.(data.domains);
alert("저장되었습니다.");
alert("저장되었습니다.", () => props.onOpenChange?.(false));
}
};
@@ -84,7 +84,7 @@ function IPManagementDialog({
const onSubmit = async (data: IPFormData) => {
if (await confirm("저장하시겠습니까?")) {
await mutateAsync(data.ips);
alert("저장되었습니다.");
alert("저장되었습니다.", () => props.onOpenChange?.(false));
}
};
+3 -4
View File
@@ -34,11 +34,10 @@ function ModifyPage({ siteId }: { siteId: string }) {
const { mutateAsync: updateSite } = useMutationWrapper({
mutationFn: (data: SiteFormData) => SiteApi.updateSite(siteId, data),
onSuccess: () => {
alert("수정되었습니다.", () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
});
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
});
alert("수정되었습니다.", router.back);
},
});
+2 -7
View File
@@ -2,8 +2,8 @@
import "@common/assets/styles/grid.css";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
@@ -24,7 +24,7 @@ function SitePage() {
});
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
queryKey: [ENDPOINT.CMS_SERVICE.SITES, pagination],
queryFn: () =>
SiteApi.getSiteList({
...siteQuery,
@@ -32,7 +32,6 @@ function SitePage() {
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
enabled: false,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
@@ -61,10 +60,6 @@ function SitePage() {
},
});
useEffect(() => {
query.refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
@@ -60,13 +60,13 @@ function ArticleManagementDialog({
enabled: !!srvyQitemNo && !!open,
});
const { mutateAsync } = useMutationWrapper({
const { mutate } = useMutationWrapper({
mutationFn: (body: Article[]) => SurveyApi.saveArticleList(body),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY_ARTICLE, srvyQitemNo],
});
alert("저장되었습니다.");
alert("저장되었습니다.", () => setOpen(false));
},
});
@@ -95,8 +95,7 @@ function ArticleManagementDialog({
const onSubmit = async (data: ArticleFormData) => {
if (await confirm("저장하시겠습니까?")) {
await mutateAsync(data.articles);
alert("저장되었습니다.");
mutate(data.articles);
}
};
@@ -43,8 +43,7 @@ function QuestionAddDialog({
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY_QUESTION, srvySn.toString()],
});
alert("문항이 추가되었습니다.");
onOpenChange?.(false);
alert("문항이 추가되었습니다.", () => onOpenChange?.(false));
},
});
@@ -50,8 +50,7 @@ function QuestionEditDialog({
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY_QUESTION, srvySn.toString()],
});
alert("문항이 수정되었습니다.");
setOpen(false);
alert("문항이 수정되었습니다.", () => setOpen(false));
},
});
@@ -116,6 +116,7 @@ function SurveyPage() {
<PostingDialog
postingType={POSTING_TYPE.SURVEY}
postingTargets={postingTargets}
resetSelectedPost={() => table.setRowSelection({})}
/>
<AuthorityButton
authority="regAuthrtYn"
@@ -26,7 +26,7 @@ function ModifyPage({ sequential }: { sequential: string }) {
mutationFn: (data: TemplateFormData) =>
TemplateApi.updateTemplate(sequential, data),
onSuccess: () => {
alert("수정되었습니다.");
alert("수정되었습니다.", router.back);
},
});
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
@@ -25,7 +25,7 @@ function TemplatePage() {
});
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.TEMPLATE],
queryKey: [ENDPOINT.CMS_SERVICE.TEMPLATE, pagination],
queryFn: () =>
TemplateApi.getTemplateList({
...templateQuery,
@@ -33,7 +33,6 @@ function TemplatePage() {
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
enabled: false,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
@@ -62,10 +61,6 @@ function TemplatePage() {
},
});
useEffect(() => {
query.refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
+1 -1
View File
@@ -22,7 +22,7 @@ function ModifyPage({ sequential }: { sequential: string }) {
const { mutateAsync: updateTerms } = useMutationWrapper({
mutationFn: (data: TermsFormData) => TermsApi.updateTerms(sequential, data),
onSuccess: () => {
alert("수정되었습니다.");
alert("수정되었습니다.", router.back);
},
});
+2 -7
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
@@ -25,7 +25,7 @@ function TermsPage() {
});
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.TERMS],
queryKey: [ENDPOINT.CMS_SERVICE.TERMS, pagination],
queryFn: () =>
TermsApi.getTermsList({
...termsQuery,
@@ -33,7 +33,6 @@ function TermsPage() {
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
enabled: false,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
@@ -62,10 +61,6 @@ function TermsPage() {
},
});
useEffect(() => {
query.refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
+1 -2
View File
@@ -37,8 +37,7 @@ function CreatePage() {
const { mutateAsync: saveUser } = useMutationWrapper({
mutationFn: (data: UserSaveData) => UserApi.saveUser(data),
onSuccess: () => {
alert("회원이 등록되었습니다.");
router.push("/users");
alert("회원이 등록되었습니다.", router.back);
},
});
+6 -3
View File
@@ -11,10 +11,9 @@ import {
TableHead,
TableRow,
} from "@common/components";
import { USER_STATE_CODE, UserApi } from "@/entities/user";
import { USER_STATE_CODE, UserApi, PasswordResetDialog } from "@/entities/user";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { PasswordResetDialog } from "./password-reset-dialog";
function UserDetail({ userUnqId }: { userUnqId: string }) {
const router = useRouter();
@@ -76,7 +75,11 @@ function UserDetail({ userUnqId }: { userUnqId: string }) {
계정잠금
</Button>
)}
<PasswordResetDialog userUnqId={userUnqId} />
<PasswordResetDialog userUnqId={userUnqId}>
<Button color="gray">
<i className="diveicon di-arrow-reset" /> 비밀번호 초기화
</Button>
</PasswordResetDialog>
{data?.userStateCode === USER_STATE_CODE.대기 && (
<Button className="emerald" onClick={() => onApplyUser()}>
<i className="diveicon di-check-circle-line" /> 가입승인
+1
View File
@@ -48,6 +48,7 @@ export const ENDPOINT = {
LOGINLOG: "/cms-service/api/v1/loginlog",
GROUPS: "/cms-service/api/v1/groups",
GROUP_USERS: "/cms-service/api/v1/group-users",
MANAGER_IP: "/cms-service/api/v1/mgrip",
},
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
@@ -20,6 +20,7 @@ import {
DropdownMenuItem,
} from "../dropdown-menu";
import { Button } from "../button";
import { SidebarTrigger } from "../sidebar";
const { SITE_ID } = getConfig();
@@ -91,7 +92,8 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
return (
<header className="sticky top-0 z-30 box-border flex h-[5rem] w-full items-center justify-between border-b border-solid border-border bg-background">
<div className="flex items-center">
<div className="flex items-center px-6">
<SidebarTrigger className="size-12 !bg-transparent p-2 hover:!bg-accent [&>svg]:!size-full [&>svg]:text-foreground" />
<div className="MobMenuBtn">
<img src={images.menuBtn} />
</div>
+79 -51
View File
@@ -14,6 +14,9 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from "../sidebar";
import {
Collapsible,
@@ -39,8 +42,7 @@ export function LNB({
enabled: !viewOnly,
});
const [openMenuIds, setOpenMenuIds] = useState<number[]>([]);
const userClosedMenusRef = useRef(new Set());
const [defaultOpenMenuIds, setDefaultOpenMenuIds] = useState<number[]>([]);
const prevPathRef = useRef(currentPath);
function isPathMatched(path: string) {
@@ -69,22 +71,29 @@ export function LNB({
findMatchedMenus(menuList);
if (matchedMenuIds.length > 0) {
const filteredIds = matchedMenuIds.filter(
(id) => !userClosedMenusRef.current.has(id),
);
setOpenMenuIds((prev) => [...new Set([...prev, ...filteredIds])]);
setDefaultOpenMenuIds((prev) => [
...new Set([...prev, ...matchedMenuIds]),
]);
}
if (prevPathRef.current !== currentPath) {
setOpenMenuIds(matchedMenuIds);
setDefaultOpenMenuIds(matchedMenuIds);
prevPathRef.current = currentPath;
}
}, [menuList, currentPath]);
function MenuItem({ item }: { item: MenuItem }) {
function MenuItem({
item,
isSubMenu,
}: {
item: MenuItem;
isSubMenu?: boolean;
}) {
const { open: isSidebarOpen, setOpen } = useSidebar();
const isMatched = isPathMatched(item.urlPath);
const handleClick = () => {
if (!isSidebarOpen) setOpen(true);
setCookie("menu-code-key", item.menuCd.toString());
handleMenuClick?.(item);
};
@@ -93,66 +102,82 @@ export function LNB({
return <MenuGroup item={item} />;
}
const MenuItem = isSubMenu ? SidebarMenuSubItem : SidebarMenuItem;
const MenuButton = isSubMenu ? SidebarMenuSubButton : SidebarMenuButton;
return (
<SidebarMenuItem>
<SidebarMenuButton
<MenuItem>
<MenuButton
className={cn(
"h-auto py-4 text-[1.4rem] text-label transition-colors hover:bg-accent",
"h-auto cursor-pointer py-4 text-[1.4rem] text-label transition-colors hover:bg-accent",
isMatched && "bg-accent !text-primary",
!isSidebarOpen && "justify-center",
)}
data-matched={isMatched}
onClick={handleClick}
tooltip={item.menuKornNm}
>
<i className={cn("diveicon ml-2", `di-${item.iconNm}`)} />
{item.menuKornNm}
</SidebarMenuButton>
</SidebarMenuItem>
<i
className={cn(
"diveicon",
`di-${item.iconNm}`,
isSidebarOpen && "ml-2",
)}
/>
<span className={cn("whitespace-nowrap", !isSidebarOpen && "hidden")}>
{item.menuKornNm}
</span>
</MenuButton>
</MenuItem>
);
}
function MenuGroup({ item }: { item: MenuItem }) {
const isOpen = openMenuIds.includes(item.menuCd);
const handleOpenChange = (open: boolean) => {
if (open) {
// 열 때는 부모들도 함께 열기
if (!openMenuIds.includes(item.menuCd)) {
const parentIds = getParentIds(item.menuCd)(menuList ?? []);
setOpenMenuIds((prev) => [...prev, ...parentIds, item.menuCd]);
}
userClosedMenusRef.current.delete(item.menuCd);
} else {
// 닫을 때는 현재 메뉴만 닫기
setOpenMenuIds((prev) => prev.filter((id) => id !== item.menuCd));
userClosedMenusRef.current.add(item.menuCd);
}
};
const { open: isSidebarOpen } = useSidebar();
return (
<Collapsible open={isOpen} onOpenChange={handleOpenChange}>
<Collapsible
defaultOpen={defaultOpenMenuIds.includes(item.menuCd)}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<div className="group">
<SidebarMenuButton className="group/menu-button flex h-auto items-center justify-between py-4 pr-2 text-[1.4rem] group-data-[state=open]:bg-transparent">
<div className="flex items-center gap-2">
<i
className={cn(
"diveicon ml-2 text-label",
`di-${item.iconNm}`,
)}
/>
<span className="text-label group-hover/menu-button:text-foreground">
{item.menuKornNm}
</span>
</div>
<ChevronRight className="!size-6 text-label group-data-[state=open]:rotate-90" />
</SidebarMenuButton>
</div>
<SidebarMenuButton
className={cn(
"flex h-auto items-center py-4 pr-2 text-[1.4rem] group-data-[state=open]/collapsible:bg-transparent",
!isSidebarOpen && "justify-center",
)}
tooltip={item.menuKornNm}
>
<div className="flex items-center gap-2">
<i
className={cn(
"diveicon text-label",
`di-${item.iconNm}`,
isSidebarOpen && "ml-2",
)}
/>
<span
className={cn(
"whitespace-nowrap text-label group-hover/menu-button:text-foreground",
!isSidebarOpen && "hidden",
)}
>
{item.menuKornNm}
</span>
</div>
<ChevronRight
className={cn(
"ml-auto !size-6 text-label group-data-[state=open]/collapsible:rotate-90",
!isSidebarOpen && "hidden",
)}
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub className="mr-0">
<SidebarMenuSub>
{item.children?.map((child) => (
<MenuItem key={child.menuCd} item={child} />
<MenuItem key={child.menuCd} item={child} isSubMenu />
))}
</SidebarMenuSub>
</CollapsibleContent>
@@ -162,7 +187,10 @@ export function LNB({
}
return (
<Sidebar className="border-border bg-background px-4 py-[6rem]">
<Sidebar
collapsible="icon"
className="border-border bg-background px-4 py-[6rem]"
>
<SidebarContent className="bg-background">
<SidebarMenu className="gap-0">
{menuList?.map((item) => <MenuItem key={item.menuCd} item={item} />)}
@@ -28,7 +28,7 @@ const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "22rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_WIDTH_ICON = "6rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
@@ -252,7 +252,7 @@ const Sidebar = React.forwardRef<
<div
data-sidebar="sidebar-wrapper"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed inset-y-0 z-20 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -530,7 +530,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-16 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -602,6 +602,7 @@ const SidebarMenuButton = React.forwardRef<
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
className="text-lg"
{...tooltip}
/>
</Tooltip>
@@ -23,12 +23,19 @@
background: none !important;
color: #fff !important;
}
[data-sidebar="menu-button"]:hover {
[data-sidebar="trigger"]:hover,
[data-sidebar="menu-button"]:hover,
[data-sidebar="menu-sub-button"]:hover {
background: rgba(0, 0, 0, 0.1) !important;
}
[data-sidebar="menu-button"][data-matched="true"] {
[data-sidebar="trigger"][data-matched="true"],
[data-sidebar="menu-button"][data-matched="true"],
[data-sidebar="menu-sub-button"][data-matched="true"] {
background: rgba(0, 0, 0, 0.2) !important;
}
[data-sidebar="trigger"] > svg {
color: #fff !important;
}
}
[data-theme="red"] {
@@ -58,11 +65,18 @@
background: none !important;
color: #fff !important;
}
[data-sidebar="menu-button"]:hover {
[data-sidebar="trigger"]:hover,
[data-sidebar="menu-button"]:hover,
[data-sidebar="menu-sub-button"]:hover {
background: rgba(0, 0, 0, 0.1) !important;
}
[data-sidebar="menu-button"][data-matched="true"] {
[data-sidebar="trigger"][data-matched="true"],
[data-sidebar="menu-button"][data-matched="true"],
[data-sidebar="menu-sub-button"][data-matched="true"] {
background: rgba(0, 0, 0, 0.2) !important;
}
[data-sidebar="trigger"] > svg {
color: #fff !important;
}
}
}