diff --git a/apps/cs/.env.example b/apps/cs/.env.example new file mode 100644 index 000000000..8020ee321 --- /dev/null +++ b/apps/cs/.env.example @@ -0,0 +1,6 @@ +# apps/cs/src/shared/config/env.ts 참고 +NEXT_PUBLIC_PORT= +NEXT_PUBLIC_SITE_ID= +NEXT_PUBLIC_SERVER_API_URL= +NEXT_PUBLIC_PROXY_HOST= +NEXT_PUBLIC_TINY_API_KEY= \ No newline at end of file diff --git a/apps/cs/.gitignore b/apps/cs/.gitignore index e8d8437ee..b63718b61 100644 --- a/apps/cs/.gitignore +++ b/apps/cs/.gitignore @@ -37,4 +37,5 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -.env* \ No newline at end of file +.env* +!.env.example \ No newline at end of file diff --git a/apps/cs/app/(with-base-layout)/admin/page.tsx b/apps/cs/app/(with-base-layout)/admin/page.tsx new file mode 100644 index 000000000..a7633e201 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/admin/page.tsx @@ -0,0 +1,2 @@ +import { AdminPage } from "@/pages/admin"; +export default AdminPage; diff --git a/apps/cs/app/(with-base-layout)/groups/page.tsx b/apps/cs/app/(with-base-layout)/groups/page.tsx new file mode 100644 index 000000000..8491e59bb --- /dev/null +++ b/apps/cs/app/(with-base-layout)/groups/page.tsx @@ -0,0 +1,2 @@ +import { GroupsPage } from "@/pages/groups"; +export default GroupsPage; diff --git a/apps/cs/app/api/getClientIp/route.ts b/apps/cs/app/api/getClientIp/route.ts new file mode 100644 index 000000000..ec9ff72d5 --- /dev/null +++ b/apps/cs/app/api/getClientIp/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const ipAddress = + request.headers.get("x-forwarded-for")?.split(",")[0] || "unknown"; + + const ipv4Regex = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/; + const match = ipAddress.match(ipv4Regex); + + const normalizedIp = match ? match[1] : ipAddress; + + return NextResponse.json({ + originalIp: ipAddress, + normalizedIp: normalizedIp, + }); +} diff --git a/apps/cs/middleware.ts b/apps/cs/middleware.ts index d56c1f8ab..6bdfd9545 100644 --- a/apps/cs/middleware.ts +++ b/apps/cs/middleware.ts @@ -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*", "/"], }; diff --git a/apps/cs/src/app/providers/app-provider.tsx b/apps/cs/src/app/providers/app-provider.tsx index 1a2189b03..b8fabff3d 100644 --- a/apps/cs/src/app/providers/app-provider.tsx +++ b/apps/cs/src/app/providers/app-provider.tsx @@ -5,6 +5,7 @@ import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; +import { ClientIpGuard } from "@/entities/ip"; import { AuthWrapper } from "@/entities/auth"; import { MenuWrapper } from "@/entities/menu"; import { PromptProvider, StoreProvider } from "@/shared/providers"; @@ -16,28 +17,30 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { const queryClient = new QueryClient(); return ( - - - - - - - {children} - - - - - - - - - -
- -
+ + + + + + + + {children} + + + + + + + + + +
+ +
+
); }; diff --git a/apps/cs/src/entities/group-user/api/group-user-service.ts b/apps/cs/src/entities/group-user/api/group-user-service.ts new file mode 100644 index 000000000..85355a88f --- /dev/null +++ b/apps/cs/src/entities/group-user/api/group-user-service.ts @@ -0,0 +1,72 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + GroupUserService, + GroupUserSearchParams, + GroupUserListResponseDto, + GroupUserDetailResponse, + PageGroupUserList, + GroupUserSaveData, + GroupUserUpdateData, +} from "../model/group-user-interface"; + +export class GroupUsersApiService implements GroupUserService { + // 그룹 사용자 목록 조회 + public async findPageGroupUser( + params: GroupUserSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.GROUP_USERS}`, + { params }, + ); + return response.data; + } + + // 그룹 사용자 단건 조회 + public async findByGroupAndUserId( + grpSn: number, + userUnqId: string, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${grpSn}/${userUnqId}`, + ); + return response.data; + } + + // 그룹 사용자 저장 + public async saveGroupUser( + data: GroupUserSaveData[], + ): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.GROUP_USERS}`, + data, + ); + return response.data; + } + + // 그룹 사용자 수정 + public async updateGroupUser( + grpSn: number, + userUnqId: string, + data: GroupUserUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${grpSn}/${userUnqId}`, + data, + ); + return response.data; + } + + // 그룹 사용자 삭제 + public async deleteGroupUser( + groupId: string, + userUnqId: string, + ): Promise { + const response = await client.delete( + `${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${groupId}/${userUnqId}`, + ); + return response.data; + } +} + +export const GroupUsersApi = new GroupUsersApiService(); diff --git a/apps/cs/src/entities/group-user/index.ts b/apps/cs/src/entities/group-user/index.ts new file mode 100644 index 000000000..349194a90 --- /dev/null +++ b/apps/cs/src/entities/group-user/index.ts @@ -0,0 +1,5 @@ +export { GroupUsersApi } from "./api/group-user-service"; +export { + type GroupUserListResponseDto, + type GroupUserSaveData, +} from "./model/group-user-interface"; diff --git a/apps/cs/src/entities/group-user/model/group-user-interface.ts b/apps/cs/src/entities/group-user/model/group-user-interface.ts new file mode 100644 index 000000000..38daf6fe0 --- /dev/null +++ b/apps/cs/src/entities/group-user/model/group-user-interface.ts @@ -0,0 +1,77 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +// 그룹 사용자 검색 파라미터 인터페이스 +export interface GroupUserSearchParams extends PageableParams { + keyword?: string; +} + +// 그룹 사용자 목록 응답 인터페이스 +export interface GroupUserListResponseDto { + mode: string; + groupId: string; + userUnqId: string; + userName: string; + ogdpSeCd: string; + ogdpSeNm: string; + mphn: string; +} + +// 그룹 사용자 페이지 응답 인터페이스 +export type PageGroupUserList = PageableList; + +// 그룹 사용자 상세 응답 인터페이스 +export interface GroupUserDetailResponse { + grpSn: number; + userUnqId: string; + userId: string; + userName: string; + email: string; + mphn: string; + regDt: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; + rgtrNm: string; + mdfrNm: string; +} + +// 그룹 사용자 저장 데이터 인터페이스 +export interface GroupUserSaveData { + mode: string; + groupId: string; + userUnqId: string; +} + +// 그룹 사용자 수정 데이터 인터페이스 +export interface GroupUserUpdateData { + userUnqId: string; +} + +// 그룹 사용자 서비스 인터페이스 +export interface GroupUserService { + // 그룹 사용자 목록 조회 + findPageGroupUser: ( + params: GroupUserSearchParams, + ) => Promise; + + // 그룹 사용자 단건 조회 + findByGroupAndUserId: ( + grpSn: number, + userUnqId: string, + ) => Promise; + + // 그룹 사용자 저장 + saveGroupUser: ( + data: GroupUserSaveData[], + ) => Promise; + + // 그룹 사용자 수정 + updateGroupUser: ( + grpSn: number, + userUnqId: string, + data: GroupUserUpdateData, + ) => Promise; + + // 그룹 사용자 삭제 + deleteGroupUser: (groupId: string, userUnqId: string) => Promise; +} diff --git a/apps/cs/src/entities/group/api/group-service.ts b/apps/cs/src/entities/group/api/group-service.ts new file mode 100644 index 000000000..fd1e879fa --- /dev/null +++ b/apps/cs/src/entities/group/api/group-service.ts @@ -0,0 +1,59 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + GroupService, + GroupSearchParams, + GroupList, + GroupResponseDto, + GroupFormData, +} from "../model/group-interface"; + +export class GroupsApiService implements GroupService { + // 그룹 목록 조회 + public async findPageGroup(params: GroupSearchParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.GROUPS}`, + { params }, + ); + return response.data; + } + + // 그룹 단건 조회 + public async findGroupById(groupId: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.GROUPS}/${groupId}`, + ); + return response.data; + } + + // 그룹 저장 + public async saveGroup(data: GroupFormData): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.GROUPS}`, + data, + ); + return response.data; + } + + // 그룹 수정 + public async updateGroup( + groupId: string, + data: GroupFormData, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.GROUPS}/${groupId}`, + data, + ); + return response.data; + } + + // 그룹 삭제 + public async deleteGroup(groupId: string): Promise { + const response = await client.delete( + `${ENDPOINT.CMS_SERVICE.GROUPS}/${groupId}`, + ); + return response.data; + } +} + +export const GroupApi = new GroupsApiService(); diff --git a/apps/cs/src/entities/group/index.ts b/apps/cs/src/entities/group/index.ts new file mode 100644 index 000000000..da1d72a41 --- /dev/null +++ b/apps/cs/src/entities/group/index.ts @@ -0,0 +1,6 @@ +export { GroupApi } from "./api/group-service"; +export { + groupFormSchema, + type GroupFormData, + type GroupListResponseDto, +} from "./model/group-interface"; diff --git a/apps/cs/src/entities/group/model/group-interface.ts b/apps/cs/src/entities/group/model/group-interface.ts new file mode 100644 index 000000000..f8cc9883f --- /dev/null +++ b/apps/cs/src/entities/group/model/group-interface.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +// 그룹 검색 파라미터 인터페이스 +export interface GroupSearchParams extends PageableParams { + keywordType?: string; + keyword?: string; + groupId?: string; + groupNm?: string; + groupSeCd?: string; + delYn?: string; + authrtId?: string; + siteId?: string; + groupExpln?: string; + roleId?: string; + mngrYn?: string; +} + +// 그룹 목록 응답 인터페이스 +export interface GroupListResponseDto { + groupId: string; + groupNm: string; + groupSeCd: string; + groupSeNm: string; + delYn: string; + authrtId: string; + groupExpln: string; + siteId: string; + mngrYn: string; + roleId: string; + roleNm: string; + mdfcnDt: string; + mdfrNm: string; +} + +// 그룹 상세 응답 인터페이스 +export interface GroupResponseDto { + groupId: string; + groupNm: string; + groupSeCd: string; + groupSeNm: string; + groupExpln: string; + delYn: string; + regDt: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; + authrtId: string; + siteId: string; + mngrYn: string; +} + +// 그룹 저장 데이터 스키마 +export const groupFormSchema = z.object({ + groupId: z.string().min(1, { message: "그룹 ID를 입력해주세요." }), + groupNm: z.string().min(1, { message: "그룹명을 입력해주세요." }), + groupSeCd: z.string().min(1, { message: "그룹구분을 선택해주세요." }), + groupExpln: z.string().optional(), + delYn: z.string().default("N"), + authrtId: z.string().min(1, { message: "권한ID를 선택해주세요." }), + siteId: z.string().optional(), + mngrYn: z.string().default("N"), +}); + +export type GroupList = PageableList; +export type GroupFormData = z.infer; + +// 그룹 서비스 인터페이스 +export interface GroupService { + // 그룹 목록 조회 + findPageGroup: (params: GroupSearchParams) => Promise; + + // 그룹 단건 조회 + findGroupById: (groupId: string) => Promise; + + // 그룹 저장 + saveGroup: (data: GroupFormData) => Promise; + + // 그룹 수정 + updateGroup: ( + groupId: string, + data: GroupFormData, + ) => Promise; + + // 그룹 삭제 + deleteGroup: (groupId: string) => Promise; +} diff --git a/apps/cs/src/pages/site/api/ip-service.ts b/apps/cs/src/entities/ip/api/ip-service.ts similarity index 89% rename from apps/cs/src/pages/site/api/ip-service.ts rename to apps/cs/src/entities/ip/api/ip-service.ts index 903f0407e..0a9e063e6 100644 --- a/apps/cs/src/pages/site/api/ip-service.ts +++ b/apps/cs/src/entities/ip/api/ip-service.ts @@ -1,6 +1,6 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; -import type { IpParams, IP, IpList, IpService } from "../model/ip-interface"; +import type { IpService, IpParams, IpList, IP } from "../model/ip-interface"; export class IpApiService implements IpService { public async getIpList(params: IpParams): Promise { diff --git a/apps/cs/src/entities/ip/index.ts b/apps/cs/src/entities/ip/index.ts new file mode 100644 index 000000000..ef1c5be33 --- /dev/null +++ b/apps/cs/src/entities/ip/index.ts @@ -0,0 +1,10 @@ +export { IpApi } from "./api/ip-service"; +export { + ipFormSchema, + type IpService, + type IPFormData, + type IpParams, + type IpList, + type IP, +} from "./model/ip-interface"; +export { ClientIpGuard } from "./lib/client-ip-guard"; diff --git a/apps/cs/src/entities/ip/lib/client-ip-guard.tsx b/apps/cs/src/entities/ip/lib/client-ip-guard.tsx new file mode 100644 index 000000000..f3ae452ce --- /dev/null +++ b/apps/cs/src/entities/ip/lib/client-ip-guard.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { SiteApi } from "@/entities/site/@x/ip"; +import { ENDPOINT, SITE_ID } from "@/shared/config"; +import { IpApi } from "../api/ip-service"; + +type ClientIpResponse = { + originalIp: string; + normalizedIp: string; +}; + +const ClientIpGuard = ({ children }: { children: React.ReactNode }) => { + const queryClient = useQueryClient(); + const [isAllowed, setIsAllowed] = useState(true); + + useEffect(() => { + (async () => { + const response = await fetch("/api/getClientIp"); + const { normalizedIp } = (await response.json()) as ClientIpResponse; + + if (!normalizedIp || !SITE_ID) return; + + const siteInfo = await queryClient.ensureQueryData({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES, SITE_ID], + queryFn: () => SiteApi.siteDetail(SITE_ID), + }); + + if (siteInfo.siteKndCd === "O") return; + + const { content: ipList } = await IpApi.getIpList({ + siteId: SITE_ID, + prmYn: "Y", + }); + + const allowedClientIp = ipList.some((ip) => ip.ipAddr === normalizedIp); + setIsAllowed(allowedClientIp); + })(); + }, []); + + if (!isAllowed) { + alert("허용되지 않은 IP로 접근하였습니다."); + return <>; + } + + return <>{children}; +}; + +export { ClientIpGuard }; diff --git a/apps/cs/src/pages/site/model/ip-interface.ts b/apps/cs/src/entities/ip/model/ip-interface.ts similarity index 100% rename from apps/cs/src/pages/site/model/ip-interface.ts rename to apps/cs/src/entities/ip/model/ip-interface.ts diff --git a/apps/cs/src/entities/menu-authority/api/menu-authority-service.ts b/apps/cs/src/entities/menu-authority/api/menu-authority-service.ts index 14988eee0..ec07d88ad 100644 --- a/apps/cs/src/entities/menu-authority/api/menu-authority-service.ts +++ b/apps/cs/src/entities/menu-authority/api/menu-authority-service.ts @@ -35,6 +35,16 @@ export class MenuAuthorityApiService implements MenuAuthorityService { ); return response.data; } + + public async getGroupMenuAuthorityList( + params: MenuAuthorityParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/group`, + { params }, + ); + return response.data; + } } export const MenuAuthorityApi = new MenuAuthorityApiService(); diff --git a/apps/cs/src/entities/menu-authority/index.ts b/apps/cs/src/entities/menu-authority/index.ts index 2a555b77e..5a1b11606 100644 --- a/apps/cs/src/entities/menu-authority/index.ts +++ b/apps/cs/src/entities/menu-authority/index.ts @@ -1,2 +1,9 @@ +export { MenuAuthorityApi } from "./api/menu-authority-service"; +export { + menuAuthorityFormSchema, + type MenuAuthority, + type MenuAuthorityPayload, + type MenuAuthorityFormData, +} from "./model/menu-authority-interface"; export { AuthorityButton } from "./ui/authority-button"; export { ManagementAuthorityDialog } from "./ui/management-authority-dialog"; diff --git a/apps/cs/src/entities/menu-authority/model/menu-authority-interface.ts b/apps/cs/src/entities/menu-authority/model/menu-authority-interface.ts index b59574c2f..7f5401872 100644 --- a/apps/cs/src/entities/menu-authority/model/menu-authority-interface.ts +++ b/apps/cs/src/entities/menu-authority/model/menu-authority-interface.ts @@ -43,6 +43,9 @@ export type MenuAuthorityFormData = z.infer; export interface MenuAuthorityService { getMenuAuthorityList(params: MenuAuthorityParams): Promise; + getGroupMenuAuthorityList( + params: MenuAuthorityParams, + ): Promise; getPageMenuAuthority(menuCd: number): Promise; saveMenuAuthority(data: MenuAuthorityPayload[]): Promise; } diff --git a/apps/cs/src/entities/menu-authority/ui/management-authority-dialog.tsx b/apps/cs/src/entities/menu-authority/ui/management-authority-dialog.tsx index 4a73cb596..a9ba18333 100644 --- a/apps/cs/src/entities/menu-authority/ui/management-authority-dialog.tsx +++ b/apps/cs/src/entities/menu-authority/ui/management-authority-dialog.tsx @@ -1,11 +1,11 @@ +import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { getCoreRowModel } from "@tanstack/react-table"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Button, - DataTable, Dialog, DialogClose, DialogContent, @@ -15,7 +15,7 @@ import { DialogTrigger, } from "@common/components"; import { ENDPOINT } from "@/shared/config"; -import { useAlertStore, useMutationWrapper } from "@/shared/lib"; +import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib"; import { menuAuthorityFormSchema, type MenuAuthorityFormData, @@ -28,14 +28,12 @@ type ManagementAuthorityDialogProps = { menuCd?: number; } & React.ComponentProps; -function ManagementAuthorityDialog({ - menuCd, - ...props -}: ManagementAuthorityDialogProps) { +function ManagementAuthorityDialog({ menuCd }: ManagementAuthorityDialogProps) { const { confirm } = useConfirm(); const alert = useAlertStore((state) => state.setMessage); + const [open, setOpen] = useState(false); - const { data: menuAuthorityList } = useQuery({ + const query = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.MENU_AUTHRTS, menuCd], queryFn: () => MenuAuthorityApi.getMenuAuthorityList({ menuCd }), enabled: !!menuCd, @@ -45,21 +43,23 @@ function ManagementAuthorityDialog({ mutationFn: (data: MenuAuthorityPayload[]) => MenuAuthorityApi.saveMenuAuthority(data), onSuccess: () => { - alert("저장되었습니다."); + alert("저장되었습니다.", () => setOpen(false)); }, }); const form = useForm({ resolver: zodResolver(menuAuthorityFormSchema), values: { - menuAuthority: menuAuthorityList?.content ?? [], + menuAuthority: query.data?.content ?? [], }, }); - const table = useReactTable({ - data: menuAuthorityList?.content ?? [], - columns: createColumns(form), - getCoreRowModel: getCoreRowModel(), + const { tableComponent } = useDataTable({ + query, + tableOptions: { + columns: createColumns(form), + getCoreRowModel: getCoreRowModel(), + }, }); const handleSave = async () => { @@ -71,7 +71,7 @@ function ManagementAuthorityDialog({ }; return ( - + + +
+ + {tableComponent} +
+
+ + +
+ + + + + +
+ ); +} + +export { AddAdminDialog }; diff --git a/apps/cs/src/pages/admin/ui/admin-page.tsx b/apps/cs/src/pages/admin/ui/admin-page.tsx new file mode 100644 index 000000000..a4cd5265d --- /dev/null +++ b/apps/cs/src/pages/admin/ui/admin-page.tsx @@ -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({ + keywordType: "", + keyword: "", + grpId: "", + }); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [rowSelection, setRowSelection] = useState({}); + + 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 }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="전체 선택" + /> + ), + cell: ({ row }: { row: Row }) => ( + 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) => { + 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 ( +
+
+
+
+ + +
+
+ + + + setAdminQuery({ ...adminQuery, keyword: e.target.value }) + } + placeholder="검색어 입력" + /> +
+ +
+
+
+
+
+

+ 관리자 목록(총 {query.data?.totalElements}명) +

+
+ + +
+
+
+
{tableComponent}
+
+
+ ); +} + +export { AdminPage }; diff --git a/apps/cs/src/pages/admin/ui/manager-ip-dialog.tsx b/apps/cs/src/pages/admin/ui/manager-ip-dialog.tsx new file mode 100644 index 000000000..a97947dc5 --- /dev/null +++ b/apps/cs/src/pages/admin/ui/manager-ip-dialog.tsx @@ -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([]); + + 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({ + 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 ( + + + + + + + 접속 IP 관리 + +
+ { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(onSubmit)(e); + }} + > +
+ + +
+
+ + + + + + + + + + + IP 주소 + + + + {fields.map((field, i) => { + if (field.mode === "D") return null; + return ( + + + handleSelectRow(i)} + /> + + + ( + + + + + + )} + /> + + + ); + })} + +
+
+ + + + + + +
+ +
+
+ ); +} + +export { ManagerIpDialog }; diff --git a/apps/cs/src/pages/banner/ui/banner-page.tsx b/apps/cs/src/pages/banner/ui/banner-page.tsx index f2dd98d51..db5a5cc15 100644 --- a/apps/cs/src/pages/banner/ui/banner-page.tsx +++ b/apps/cs/src/pages/banner/ui/banner-page.tsx @@ -5,13 +5,13 @@ import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { getCoreRowModel, - useReactTable, type PaginationState, type RowSelectionState, } from "@tanstack/react-table"; -import { Button, DataTable, Input, Label } from "@common/components"; +import { Button, Input, Label } from "@common/components"; import { POSTING_TYPE, PostingDialog } from "@/entities/posting"; import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; import { BannerApi } from "../api/banner-service"; import { createColumns } from "../model/banner-table-columns"; import { type Banner, type BannerParams } from "../model/banner-interface"; @@ -34,35 +34,19 @@ function BannerPage() { pageSize: 5, }); - const { data: bannerList, refetch } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.BANNERS], + const query = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.BANNERS, pagination], queryFn: () => BannerApi.getBannerList({ ...bannerQuery, page: pagination.pageIndex, size: pagination.pageSize, }), - enabled: false, - }); - - const table = useReactTable({ - data: bannerList?.content ?? [], - columns: createColumns(imageRefs), - state: { - pagination, - rowSelection, - }, - rowCount: bannerList?.totalElements ?? 0, - manualPagination: true, - enableMultiRowSelection: true, - getCoreRowModel: getCoreRowModel(), - onPaginationChange: setPagination, - onRowSelectionChange: setRowSelection, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - refetch(); + query.refetch(); }; const handleRowClick = (row: Banner) => { @@ -78,6 +62,26 @@ function BannerPage() { return selectedImages; }; + const { tableComponent, tableInstance: table } = useDataTable({ + query, + tableOptions: { + columns: createColumns(imageRefs), + state: { + pagination, + rowSelection, + }, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }, + dataTableOptions: { + onRowClick: handleRowClick, + viewPagination: true, + }, + }); + useEffect(() => { const postingTargets = table .getFilteredSelectedRowModel() @@ -94,10 +98,6 @@ function BannerPage() { setPostingTargets(postingTargets); }, [rowSelection]); - useEffect(() => { - refetch(); - }, [pagination]); - return (
@@ -126,6 +126,7 @@ function BannerPage() { table.setRowSelection({})} />
-
- -
+
{tableComponent}
); diff --git a/apps/cs/src/pages/banner/ui/modify-page.tsx b/apps/cs/src/pages/banner/ui/modify-page.tsx index 070db0e46..fa9c17705 100644 --- a/apps/cs/src/pages/banner/ui/modify-page.tsx +++ b/apps/cs/src/pages/banner/ui/modify-page.tsx @@ -5,15 +5,11 @@ import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - getCoreRowModel, - useReactTable, - type RowSelectionState, -} from "@tanstack/react-table"; +import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { Button, DataTable } from "@common/components"; +import { Button } from "@common/components"; import { PostingApi, type PostPayload } from "@/entities/posting"; -import { useAlertStore, useMutationWrapper } from "@/shared/lib"; +import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; import { bannerFormSchema, @@ -36,7 +32,7 @@ function ModifyPage({ sequential }: { sequential: string }) { queryFn: () => BannerApi.getBannerInfo(sequential), }); - const { data: postHistory } = useQuery({ + const query = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, sequential], queryFn: () => PostingApi.getPostingHistory({ pstgTrgtNo: parseInt(sequential) }), @@ -46,7 +42,7 @@ function ModifyPage({ sequential }: { sequential: string }) { mutationFn: (data: BannerFormData) => BannerApi.updateBanner(sequential, data), onSuccess: () => { - alert("수정되었습니다."); + alert("수정되었습니다.", router.back); }, }); @@ -72,15 +68,17 @@ function ModifyPage({ sequential }: { sequential: string }) { values: bannerDetail, }); - const table = useReactTable({ - data: postHistory?.content ?? [], - columns, - state: { - rowSelection, + const { tableComponent, tableInstance: table } = useDataTable({ + query, + tableOptions: { + columns, + state: { + rowSelection, + }, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onRowSelectionChange: setRowSelection, }, - enableMultiRowSelection: true, - getCoreRowModel: getCoreRowModel(), - onRowSelectionChange: setRowSelection, }); const handleDeletePostingHistory = async () => { @@ -122,7 +120,7 @@ function ModifyPage({ sequential }: { sequential: string }) { 이력삭제 - + {tableComponent} diff --git a/apps/cs/src/pages/bbs/ui/bbs-page.tsx b/apps/cs/src/pages/bbs/ui/bbs-page.tsx index 6bde82cce..482966fc5 100644 --- a/apps/cs/src/pages/bbs/ui/bbs-page.tsx +++ b/apps/cs/src/pages/bbs/ui/bbs-page.tsx @@ -1,17 +1,15 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { getCoreRowModel, - useReactTable, type PaginationState, type RowSelectionState, } from "@tanstack/react-table"; import { Button, - DataTable, Input, Label, Select, @@ -23,6 +21,7 @@ import { import { CodeSelect } from "@/entities/code"; import { BoardApi, type BoardParams } from "@/entities/board"; import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; import { type Board } from "@/entities/board"; import { columns } from "../model/table-columns"; @@ -39,44 +38,44 @@ function BbsPage() { pageSize: 10, }); - const { data: boardList, refetch } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.BBS], + const query = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.BBS, pagination], queryFn: () => BoardApi.getBoardList({ ...boardParams, page: pagination.pageIndex, size: pagination.pageSize, }), - enabled: false, - }); - - const table = useReactTable({ - data: boardList?.content ?? [], - columns, - state: { - pagination, - rowSelection, - }, - rowCount: boardList?.totalElements ?? 0, - manualPagination: true, - enableMultiRowSelection: true, - getCoreRowModel: getCoreRowModel(), - onPaginationChange: setPagination, - onRowSelectionChange: setRowSelection, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - refetch(); + query.refetch(); }; const handleRowClick = (row: Board) => { router.push(`/bbs/register/${row.bbsId}`); }; - useEffect(() => { - refetch(); - }, [pagination]); + const { tableComponent, tableInstance: table } = useDataTable({ + query, + tableOptions: { + columns, + state: { + pagination, + rowSelection, + }, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }, + dataTableOptions: { + onRowClick: handleRowClick, + viewPagination: true, + }, + }); return (
@@ -134,9 +133,7 @@ function BbsPage() {
-
- -
+
{tableComponent}
); diff --git a/apps/cs/src/pages/bbs/ui/modify-page.tsx b/apps/cs/src/pages/bbs/ui/modify-page.tsx index 881928297..c65b823c2 100644 --- a/apps/cs/src/pages/bbs/ui/modify-page.tsx +++ b/apps/cs/src/pages/bbs/ui/modify-page.tsx @@ -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); }, }); diff --git a/apps/cs/src/pages/code/ui/lwpocode-section.tsx b/apps/cs/src/pages/code/ui/lwpocode-section.tsx index fd1df2d8e..2d90e99a2 100644 --- a/apps/cs/src/pages/code/ui/lwpocode-section.tsx +++ b/apps/cs/src/pages/code/ui/lwpocode-section.tsx @@ -1,13 +1,10 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { - getCoreRowModel, - useReactTable, - type PaginationState, -} from "@tanstack/react-table"; -import { Button, DataTable, Input, Label } from "@common/components"; +import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; +import { Button, Input, Label } from "@common/components"; import { LwpoCodeApi, type LwpoCodeParams } from "@/entities/code"; import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; import { columns } from "../model/lwpocode-table-columns"; import { LwpoCodeManagementDialog } from "./lwpocode-dialog"; @@ -25,7 +22,7 @@ function LwpoCodeSection({ upCd }: LwpoCodeSectionProps) { pageSize: 10, }); - const { data: lwpoCodeList, refetch } = useQuery({ + const query = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, upCd, pagination], queryFn: () => LwpoCodeApi.getLwpoCodeList({ @@ -38,23 +35,29 @@ function LwpoCodeSection({ upCd }: LwpoCodeSectionProps) { placeholderData: keepPreviousData, }); - const table = useReactTable({ - data: lwpoCodeList?.content ?? [], - columns, - rowCount: lwpoCodeList?.totalElements ?? 0, - state: { pagination }, - manualPagination: true, - enableMultiRowSelection: false, - getCoreRowModel: getCoreRowModel(), - onPaginationChange: setPagination, - getRowId: (row) => row.lwpoCd, + const { tableComponent } = useDataTable({ + query, + tableOptions: { + columns, + state: { pagination }, + manualPagination: true, + enableMultiRowSelection: false, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + getRowId: (row) => row.lwpoCd, + }, + dataTableOptions: { viewPagination: true }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - refetch(); + query.refetch(); }; + useEffect(() => { + setPagination({ ...pagination, pageIndex: 0 }); + }, [upCd]); + return (
@@ -88,7 +91,7 @@ function LwpoCodeSection({ upCd }: LwpoCodeSectionProps) {
- + {tableComponent}
diff --git a/apps/cs/src/pages/code/ui/upcode-section.tsx b/apps/cs/src/pages/code/ui/upcode-section.tsx index 7e3baefc9..ffae0f28b 100644 --- a/apps/cs/src/pages/code/ui/upcode-section.tsx +++ b/apps/cs/src/pages/code/ui/upcode-section.tsx @@ -1,13 +1,10 @@ import { useState } from "react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { - getCoreRowModel, - useReactTable, - type PaginationState, -} from "@tanstack/react-table"; -import { Button, DataTable, Input, Label } from "@common/components"; +import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; +import { Button, Input, Label } from "@common/components"; import { UpCodeApi, type UpCode, type UpCodeParams } from "@/entities/code"; import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; import { columns } from "../model/upcode-table-columns"; import { UpCodeManagementDialog } from "./upcode-dialog"; @@ -25,7 +22,7 @@ function UpCodeSection({ handleSelectedUpCd }: UpCodeSectionProps) { pageSize: 10, }); - const { data: upCodeList, refetch } = useQuery({ + const query = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.UP_CODE, pagination], queryFn: () => UpCodeApi.getUpCodeList({ @@ -36,18 +33,6 @@ function UpCodeSection({ handleSelectedUpCd }: UpCodeSectionProps) { placeholderData: keepPreviousData, }); - const table = useReactTable({ - data: upCodeList?.content ?? [], - columns, - rowCount: upCodeList?.totalElements ?? 0, - state: { pagination }, - manualPagination: true, - enableMultiRowSelection: false, - getCoreRowModel: getCoreRowModel(), - onPaginationChange: setPagination, - getRowId: (row) => row.upCd, - }); - const handleRowClick = (row: UpCode) => { table.setRowSelection({ [row.upCd]: true }); handleSelectedUpCd(row.upCd); @@ -55,9 +40,23 @@ function UpCodeSection({ handleSelectedUpCd }: UpCodeSectionProps) { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - refetch(); + query.refetch(); }; + const { tableComponent, tableInstance: table } = useDataTable({ + query, + tableOptions: { + columns, + state: { pagination }, + manualPagination: true, + enableMultiRowSelection: false, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + getRowId: (row) => row.upCd, + }, + dataTableOptions: { onRowClick: handleRowClick, viewPagination: true }, + }); + return (
@@ -91,7 +90,7 @@ function UpCodeSection({ handleSelectedUpCd }: UpCodeSectionProps) {
- + {tableComponent}
diff --git a/apps/cs/src/pages/contents/model/table-columns.tsx b/apps/cs/src/pages/contents/model/table-columns.tsx index 19d98a48b..714fa2230 100644 --- a/apps/cs/src/pages/contents/model/table-columns.tsx +++ b/apps/cs/src/pages/contents/model/table-columns.tsx @@ -8,7 +8,12 @@ export const columns = [ columnHelper.display({ id: "no", header: "NO", - cell: ({ row }) => row.index + 1, + 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); + }, }), columnHelper.accessor("contsTtl", { id: "contsTtl", diff --git a/apps/cs/src/pages/contents/ui/contents-page.tsx b/apps/cs/src/pages/contents/ui/contents-page.tsx index 4fc9ae798..45d9f100e 100644 --- a/apps/cs/src/pages/contents/ui/contents-page.tsx +++ b/apps/cs/src/pages/contents/ui/contents-page.tsx @@ -1,20 +1,17 @@ "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, - useReactTable, - type PaginationState, -} from "@tanstack/react-table"; -import { Button, DataTable, Input, Label } from "@common/components"; +import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; +import { Button, Input, Label } from "@common/components"; import { ContentsApi, type Contents, type ContentsParams, } from "@/entities/content"; import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; import { columns } from "../model/table-columns"; function ContentsPage() { @@ -28,8 +25,8 @@ function ContentsPage() { pageSize: 5, }); - const { data: contentsList, refetch } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS], + const query = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS, pagination], queryFn: () => ContentsApi.getContentsList({ ...contentsQuery, @@ -37,34 +34,35 @@ function ContentsPage() { size: pagination.pageSize, }), placeholderData: keepPreviousData, - enabled: false, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - refetch(); + query.refetch(); }; const handleRowClick = (row: Contents) => { router.push(`/contents/register/${row.contsSn}`); }; - const table = useReactTable({ - data: contentsList?.content ?? [], - columns, - rowCount: contentsList?.totalElements ?? 0, - state: { - pagination, + const { tableComponent } = useDataTable({ + query, + tableOptions: { + columns, + state: { + pagination, + }, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }, + dataTableOptions: { + onRowClick: handleRowClick, + viewPagination: true, + variant: "secondary", }, - onPaginationChange: setPagination, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, }); - useEffect(() => { - refetch(); - }, [pagination]); - return (
@@ -89,14 +87,7 @@ function ContentsPage() { 등록
-
- -
+
{tableComponent}
); diff --git a/apps/cs/src/pages/file/model/table-columns.tsx b/apps/cs/src/pages/file/model/table-columns.tsx index 35d5507eb..fa30cb0fd 100644 --- a/apps/cs/src/pages/file/model/table-columns.tsx +++ b/apps/cs/src/pages/file/model/table-columns.tsx @@ -11,7 +11,12 @@ export const createColumns = ( { id: "no", header: "NO", - cell: (info) => info.row.index + 1, + 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: "atflId", diff --git a/apps/cs/src/pages/file/ui/file-page.tsx b/apps/cs/src/pages/file/ui/file-page.tsx index 9b58b3828..67a313423 100644 --- a/apps/cs/src/pages/file/ui/file-page.tsx +++ b/apps/cs/src/pages/file/ui/file-page.tsx @@ -1,16 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { - getCoreRowModel, - useReactTable, - type PaginationState, -} from "@tanstack/react-table"; +import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { Button, DataTable, Input, Label } from "@common/components"; +import { Button, Input, Label } from "@common/components"; import { AttachmentApi, type AttachmentParams } from "@/entities/attachment"; -import { useAlertStore, useMutationWrapper } from "@/shared/lib"; +import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; import { createColumns } from "../model/table-columns"; @@ -19,15 +15,15 @@ function FilePage() { const alert = useAlertStore((state) => state.setMessage); const [attachmentQuery, setAttachmentQuery] = useState({ - keyword: "", + orgnlFileNm: "", }); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10, }); - const { data: attachmentsList, refetch } = useQuery({ - queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS], + const query = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, pagination], queryFn: () => AttachmentApi.getAttachmentsList({ ...attachmentQuery, @@ -35,14 +31,13 @@ function FilePage() { size: pagination.pageSize, }), placeholderData: keepPreviousData, - enabled: false, }); const { mutate: deleteAttachment } = useMutationWrapper({ mutationFn: (atflId: string) => AttachmentApi.deleteAttachmentByAtflId(atflId), onSuccess: () => { - refetch(); + query.refetch(); alert("삭제되었습니다."); }, }); @@ -53,16 +48,20 @@ function FilePage() { } }; - const table = useReactTable({ - data: attachmentsList?.content ?? [], - columns: createColumns(handleDeleteAttachment), - rowCount: attachmentsList?.totalElements ?? 0, - state: { - pagination, + const { tableComponent } = useDataTable({ + query, + tableOptions: { + columns: createColumns(handleDeleteAttachment), + state: { + pagination, + }, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }, + dataTableOptions: { + viewPagination: true, }, - onPaginationChange: setPagination, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, }); const handleAttachmentQueryChange = @@ -72,13 +71,9 @@ function FilePage() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - refetch(); + query.refetch(); }; - useEffect(() => { - refetch(); - }, [pagination]); - return (
@@ -91,9 +86,9 @@ function FilePage() { - handleAttachmentQueryChange("keyword")(e.target.value) + handleAttachmentQueryChange("orgnlFileNm")(e.target.value) } placeholder="파일명을 입력하세요" /> @@ -108,9 +103,7 @@ function FilePage() {

파일 목록

-
- -
+
{tableComponent}
); diff --git a/apps/cs/src/pages/groups/index.ts b/apps/cs/src/pages/groups/index.ts new file mode 100644 index 000000000..269abb44a --- /dev/null +++ b/apps/cs/src/pages/groups/index.ts @@ -0,0 +1 @@ +export { GroupsPage } from "./ui/groups-page"; diff --git a/apps/cs/src/pages/groups/model/group-menu-authority-table-columns.tsx b/apps/cs/src/pages/groups/model/group-menu-authority-table-columns.tsx new file mode 100644 index 000000000..0a56a6701 --- /dev/null +++ b/apps/cs/src/pages/groups/model/group-menu-authority-table-columns.tsx @@ -0,0 +1,117 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import type { UseFormReturn } from "react-hook-form"; +import { Checkbox } from "@common/components"; +import type { + MenuAuthority, + MenuAuthorityPayload, + MenuAuthorityFormData, +} from "@/entities/menu-authority"; + +export const createColumns = ( + form: UseFormReturn, +): ColumnDef[] => { + const getCurrentRow = (i: number) => form.watch("menuAuthority")[i]; + + const handleCheckedChange = + (i: number, key: keyof MenuAuthorityPayload) => (checked: boolean) => { + if (getCurrentRow(i)?.mode === "R") { + form.setValue(`menuAuthority.${i}.mode`, "U"); + } + form.setValue(`menuAuthority.${i}.${key}`, checked ? "Y" : "N"); + }; + + return [ + { + accessorKey: "menuNm", + header: "메뉴명", + }, + { + accessorKey: "mngAuthrtYn", + header: "관리권한", + cell: ({ row }) => { + return ( + { + if (checked) { + handleCheckedChange( + row.index, + "regAuthrtYn", + )(checked as boolean); + handleCheckedChange( + row.index, + "mdfcnAuthrtYn", + )(checked as boolean); + handleCheckedChange( + row.index, + "delAuthrtYn", + )(checked as boolean); + handleCheckedChange( + row.index, + "inqAuthrtYn", + )(checked as boolean); + } + handleCheckedChange(row.index, "mngAuthrtYn")(checked as boolean); + }} + /> + ); + }, + }, + { + accessorKey: "regAuthrtYn", + header: "등록권한", + cell: ({ row }) => { + return ( + { + if (checked) { + handleCheckedChange( + row.index, + "inqAuthrtYn", + )(checked as boolean); + } + handleCheckedChange(row.index, "regAuthrtYn")(checked as boolean); + }} + /> + ); + }, + }, + { + accessorKey: "mdfcnAuthrtYn", + header: "수정권한 ", + cell: ({ row }) => { + return ( + + ); + }, + }, + { + accessorKey: "delAuthrtYn", + header: "삭제권한", + cell: ({ row }) => { + return ( + + ); + }, + }, + { + accessorKey: "inqAuthrtYn", + header: "조회권한", + cell: ({ row }) => { + return ( + + ); + }, + }, + ]; +}; diff --git a/apps/cs/src/pages/groups/model/group-users-table-columns.tsx b/apps/cs/src/pages/groups/model/group-users-table-columns.tsx new file mode 100644 index 000000000..8d1054aac --- /dev/null +++ b/apps/cs/src/pages/groups/model/group-users-table-columns.tsx @@ -0,0 +1,57 @@ +import { Checkbox } from "@common/components"; +import { type Table, type Row, type ColumnDef } from "@tanstack/react-table"; +import { type GroupUserListResponseDto } from "@/entities/group-user"; + +export const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }: { table: Table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="전체 선택" + /> + ), + cell: ({ row }: { row: Row }) => ( + row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "groupId", + header: "사용자ID", + }, + { + accessorKey: "userName", + header: "이름", + }, + { + accessorKey: "ogdpSeNm", + header: "소속", + }, + { + accessorKey: "mphn", + header: "연락처", + cell: ({ row }) => { + const phone = row.getValue("mphn") as string; + if (!phone) return ""; + + const numbers = phone.replace(/\D/g, ""); + if (numbers.length === 10) { + return `${numbers.slice(0, 3)}-${numbers.slice(3, 6)}-${numbers.slice(6)}`; + } else if (numbers.length === 11) { + return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7)}`; + } else { + return phone; + } + }, + }, +]; diff --git a/apps/cs/src/pages/groups/model/groups-table-columns.tsx b/apps/cs/src/pages/groups/model/groups-table-columns.tsx new file mode 100644 index 000000000..70f91efa0 --- /dev/null +++ b/apps/cs/src/pages/groups/model/groups-table-columns.tsx @@ -0,0 +1,37 @@ +import { type ColumnDef } from "@tanstack/react-table"; +import { Button } from "@common/components"; +import { type GroupListResponseDto } from "@/entities/group"; +import { MenuAuthorityDialog } from "../ui/menu-authority-dialog"; +import { GroupsDialog } from "../ui/groups-dialog"; + +export const columns: ColumnDef[] = [ + { + accessorKey: "groupId", + header: "그룹코드", + }, + { + accessorKey: "groupNm", + header: "그룹명", + }, + { + accessorKey: "siteNm", + header: "사이트", + }, + { + id: "actions", + header: "관리", + cell: ({ row }) => ( +
e.stopPropagation()} + className="flex justify-center gap-2" + > + + + + +
+ ), + }, +]; diff --git a/apps/cs/src/pages/groups/ui/add-user-dialog.tsx b/apps/cs/src/pages/groups/ui/add-user-dialog.tsx new file mode 100644 index 000000000..aafba70db --- /dev/null +++ b/apps/cs/src/pages/groups/ui/add-user-dialog.tsx @@ -0,0 +1,191 @@ +"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, +} from "@common/components"; +import { + UserApi, + type UserListResponse, + type UserSearchParams, +} from "@/entities/user"; +import { GroupUsersApi } from "@/entities/group-user"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useDataTable } from "@/shared/lib"; + +type AdditionalUserDialogProps = { + groupId?: string; +}; + +function AddUserDialog({ groupId }: AdditionalUserDialogProps) { + const queryClient = useQueryClient(); + const alert = useAlertStore((state) => state.setMessage); + + const [open, setOpen] = useState(false); + const [userSearchQuery, setUserSearchQuery] = useState({ + userName: "", + }); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [userRowSelection, setUserRowSelection] = useState( + {}, + ); + + const query = useQuery({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userSearchQuery, pagination], + queryFn: () => + UserApi.findPageUser({ + ...userSearchQuery, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + enabled: open, + placeholderData: keepPreviousData, + }); + + const userColumns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="전체 선택" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "userId", + header: "사용자ID", + }, + { + accessorKey: "userName", + header: "이름", + }, + { + accessorKey: "ogdpSeNm", + header: "소속", + }, + { + accessorKey: "mphn", + header: "연락처", + }, + ]; + + const { tableComponent, tableInstance: table } = useDataTable({ + query, + tableOptions: { + columns: userColumns, + state: { pagination, rowSelection: userRowSelection }, + manualPagination: true, + enableRowSelection: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setUserRowSelection, + getRowId: (row) => row.userUnqId, + }, + }); + + const handleAddUsers = async () => { + const selectedRows = Object.keys(userRowSelection); + if (selectedRows.length === 0 || !groupId) return; + + try { + const usersToAdd = selectedRows.map((userUnqId) => ({ + mode: "C", + groupId, + userUnqId, + })); + + await GroupUsersApi.saveGroupUser(usersToAdd); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.GROUP_USERS, groupId], + }); + alert("사용자 추가를 완료했습니다.", () => { + setOpen(false); + setUserRowSelection({}); + }); + } catch (error) { + console.error(error); + } + }; + + const handleSearch = () => { + setPagination({ pageIndex: 0, pageSize: 10 }); + }; + + return ( + + + + + + + 사용자 추가 + +
+ + setUserSearchQuery({ + ...userSearchQuery, + userName: e.target.value, + }) + } + className="!text-[1.3rem]" + placeholder="사용자 이름으로 검색" + /> + +
+ {tableComponent} + + + + +
+
+ ); +} + +export { AddUserDialog }; diff --git a/apps/cs/src/pages/groups/ui/group-form.tsx b/apps/cs/src/pages/groups/ui/group-form.tsx new file mode 100644 index 000000000..423718435 --- /dev/null +++ b/apps/cs/src/pages/groups/ui/group-form.tsx @@ -0,0 +1,204 @@ +"use client"; + +import type { UseFormReturn } from "react-hook-form"; +import { + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Textarea, +} from "@common/components"; +import { + FormControl, + FormField, + FormItem, + FormMessage, +} 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"; + +type GroupFormProps = { + form: UseFormReturn; +}; + +function GroupForm({ form }: GroupFormProps) { + return ( + + + + + + + + 그룹코드 + + ( + + + + + + + )} + /> + + + + 그룹명 + + ( + + + + + + + )} + /> + + + + 그룹구분 + + ( + + + + + + + )} + /> + + + + 그룹설명 + + ( + + +