From af967ab7f4f4893c79688f3dc87ff20d08ab877b Mon Sep 17 00:00:00 2001 From: swcho Date: Fri, 13 Jun 2025 09:19:20 +0900 Subject: [PATCH 001/390] =?UTF-8?q?fix:=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EC=8B=9C=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=ED=95=9C?= =?UTF-8?q?=EA=B8=80=20=EA=B9=A8=EC=A7=90=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/lib/utils/fileDownload.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/components/lib/utils/fileDownload.ts b/apps/sn/app/components/lib/utils/fileDownload.ts index ffe47eced..31fcfac76 100644 --- a/apps/sn/app/components/lib/utils/fileDownload.ts +++ b/apps/sn/app/components/lib/utils/fileDownload.ts @@ -15,7 +15,7 @@ export const handleDownload = async (api:string, fileNm:string) => { // 다운로드를 트리거할 가짜 태그 생성 const link = document.createElement('a'); link.href = url; - link.download = fileNm; // 원하는 파일명 지정 + link.download = fileNm.normalize('NFC'); // 원하는 파일명 지정 document.body.appendChild(link); link.click(); From 7bab3ef97d8825fc408a2aec75b8970c6be04883 Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 12 Jun 2025 16:50:04 +0900 Subject: [PATCH 002/390] =?UTF-8?q?WIP:=20=ED=9A=8C=EC=9B=90=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=ED=8F=BC=20=EC=9E=91=EC=97=85=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/register/page.tsx | 2 + apps/cs/src/entities/code/ui/code-select.tsx | 6 +- apps/cs/src/entities/user/index.ts | 2 + .../src/entities/user/model/user-interface.ts | 90 ++- apps/cs/src/pages/users/api/zip-service.ts | 41 ++ apps/cs/src/pages/users/index.ts | 1 + .../src/pages/users/model/table-columns.tsx | 8 +- apps/cs/src/pages/users/ui/create-page.tsx | 64 ++ apps/cs/src/pages/users/ui/users-form.tsx | 628 ++++++++++++++++++ .../src/pages/users/ui/zip-search-dialog.tsx | 217 ++++++ apps/cs/src/shared/config/api.ts | 1 + 11 files changed, 1019 insertions(+), 41 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/users/register/page.tsx create mode 100644 apps/cs/src/pages/users/api/zip-service.ts create mode 100644 apps/cs/src/pages/users/ui/create-page.tsx create mode 100644 apps/cs/src/pages/users/ui/users-form.tsx create mode 100644 apps/cs/src/pages/users/ui/zip-search-dialog.tsx diff --git a/apps/cs/app/(with-base-layout)/users/register/page.tsx b/apps/cs/app/(with-base-layout)/users/register/page.tsx new file mode 100644 index 000000000..31e36e41b --- /dev/null +++ b/apps/cs/app/(with-base-layout)/users/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/users"; +export default CreatePage; diff --git a/apps/cs/src/entities/code/ui/code-select.tsx b/apps/cs/src/entities/code/ui/code-select.tsx index 850282375..08df4ce51 100644 --- a/apps/cs/src/entities/code/ui/code-select.tsx +++ b/apps/cs/src/entities/code/ui/code-select.tsx @@ -18,7 +18,6 @@ type CodeSelectProps = { function CodeSelect({ upCd, - defaultValue, placeholder = "선택", className, ...props @@ -29,13 +28,10 @@ function CodeSelect({ enabled: !!upCd, }); - const defaultValueText = - code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? placeholder; - return ( { + field.onChange(e); + setIsValidUserId(false); + }} + className="w-[24rem] !text-[1.3rem]" + placeholder="아이디를 입력하세요" + disabled={isModifyMode} + /> + + + {!isModifyMode && ( + + )} + + )} + /> + + + + {/* 비밀번호 */} + + 비밀번호 + + ( + + + { + field.onChange(e); + handlePasswordChange(e.target.value); + }} + className="w-[24rem] !text-[1.3rem]" + placeholder="비밀번호를 입력하세요" + /> + + + + )} + /> + + + + {/* 비밀번호 확인 */} + + 비밀번호 확인 + +
+ + handlePasswordConfirmChange(e.target.value) + } + className="w-[24rem] !text-[1.3rem]" + placeholder="비밀번호를 다시 입력하세요" + /> + {!passwordMatch && passwordConfirm && ( +

+ 비밀번호가 일치하지 않습니다. +

+ )} +
+
+
+ + {/* 비밀번호 힌트 */} + + 비밀번호 힌트 + + ( + + + + + + )} + /> + + + + {/* 비밀번호 정답 */} + + 비밀번호 정답 + + ( + + + + + + )} + /> + + + + {/* 이름 */} + + 이름 + + ( + + + + + + )} + /> + + + + {/* 연락처 */} + + 연락처 + + ( + + + + + + )} + /> + + + + + ( + + + + + + )} + /> + + + + {/* 이메일 */} + + 이메일 + + ( +
+ + + { + field.onChange(e); + setIsValidEmail(false); + }} + className="w-[24rem] !text-[1.3rem]" + placeholder="이메일을 입력하세요" + /> + + + +
+ )} + /> +
+
+ + {/* 주소 */} + + 주소 + +
+
+ ( + + + + + + )} + /> + +
+ ( + + + + + + )} + /> +
+
+
+ + + ( + + + + + + )} + /> + + + + {/* 기관구분 */} + + 기관구분 + + ( + + + { + field.onChange(value); + form.setValue("dtlInstCd", ""); + form.setValue("laeSeCd", ""); + form.setValue("enoCd", ""); + }} + className="w-[16rem]" + placeholder="기관 선택" + /> + + + + )} + /> + + + + {/* 세부기관 */} + + 세부기관 + + ( + + + + + + )} + /> + + + + {/* 소속 */} + + 소속 + + ( + + + + + + )} + /> + + + + {/* 환경청 */} + + 환경청 + + ( + + + + + + )} + /> + + + + {/* 환경청 기관/업체명 */} + + 기관/업체명 + + ( + + + + + + )} + /> + + + + + + + ); +} + +export { UsersForm }; diff --git a/apps/cs/src/pages/users/ui/zip-search-dialog.tsx b/apps/cs/src/pages/users/ui/zip-search-dialog.tsx new file mode 100644 index 000000000..be59c61ee --- /dev/null +++ b/apps/cs/src/pages/users/ui/zip-search-dialog.tsx @@ -0,0 +1,217 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + type PaginationState, +} from "@tanstack/react-table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogTrigger, + DialogClose, + Button, + Input, + Label, + DataTable, +} from "@common/components"; +import { ENDPOINT } from "@/shared/config"; +import { ZipApi, type Zip, type ZipSearchParams } from "../api/zip-service"; + +interface ZipSearchDialogProps { + onSelectAddress: (data: { zip: string; addr: string }) => void; +} + +function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) { + const [open, setOpen] = useState(false); + const [selectedAddress, setSelectedAddress] = useState(null); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [searchQuery, setSearchQuery] = useState({ + roadNm: "", + lotnoMno: null, + lotnoSno: null, + }); + const hasFetchedOnce = useRef(false); + + const fetchZipData = useCallback( + () => + ZipApi.searchZip({ + ...searchQuery, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + [pagination.pageIndex, pagination.pageSize], + ); + + const { data, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.ZIPS, pagination], + queryFn: fetchZipData, + placeholderData: keepPreviousData, + enabled: false, + }); + + const handleSearch = async () => { + await refetch(); + setSelectedAddress(null); + hasFetchedOnce.current = true; + }; + + const handleRowClick = (row: Zip) => { + table.setRowSelection({ + [row.zipId.toString()]: true, + }); + setSelectedAddress(row); + }; + + const handleSelectAddress = () => { + onSelectAddress({ + zip: selectedAddress?.zoneNo ?? "", + addr: selectedAddress?.zipAddr ?? "", + }); + setOpen(false); + }; + + const table = useReactTable({ + data: data?.content ?? [], + columns: [ + { + accessorKey: "zoneNo", + header: "우편번호", + }, + { + accessorKey: "zipAddr", + header: "주소", + }, + ], + state: { + pagination, + }, + rowCount: data?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: false, + getRowId: (row) => row.zipId.toString(), + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + useEffect(() => { + if (hasFetchedOnce.current) refetch(); + }, [pagination, refetch]); + + return ( + + + + + + + 주소 검색 + + +
{ + e.preventDefault(); + e.stopPropagation(); + handleSearch(); + }} + className="mb-4 space-y-2" + > +
+
+
+ + + setSearchQuery({ + ...searchQuery, + roadNm: e.target.value, + }) + } + placeholder="예: 테헤란로" + className="!text-[1.3rem]" + /> +
+ +
+ + { + const value = e.target.value + ? parseInt(e.target.value) + : null; + setSearchQuery({ + ...searchQuery, + lotnoMno: value, + }); + }} + placeholder="예: 123" + className="!text-[1.3rem]" + /> +
+ +
+ + { + const value = e.target.value + ? parseInt(e.target.value) + : null; + setSearchQuery({ + ...searchQuery, + lotnoSno: value, + }); + }} + placeholder="예: 45" + className="!text-[1.3rem]" + /> +
+
+ +
+ +
+
+
+ + {data?.content.length && ( + + )} + + + + + + + +
+
+ ); +} + +export { ZipSearchDialog }; diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 3eba9fa91..da5edaea1 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -44,6 +44,7 @@ export const ENDPOINT = { SURVEY: "/cms-service/api/v1/srvy", SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem", SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl", + ZIPS: "/cms-service/api/v1/zips", }, BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", From bfc68de65ecf1311e346c2daf2bd4b3751754e0e Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 13 Jun 2025 11:29:12 +0900 Subject: [PATCH 003/390] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/user/model/user-interface.ts | 32 ++-- .../pages/users/ui/agency-search-dialog.tsx | 175 ++++++++++++++++++ apps/cs/src/pages/users/ui/users-form.tsx | 99 +++++----- .../src/pages/users/ui/zip-search-dialog.tsx | 73 ++++---- 4 files changed, 286 insertions(+), 93 deletions(-) create mode 100644 apps/cs/src/pages/users/ui/agency-search-dialog.tsx diff --git a/apps/cs/src/entities/user/model/user-interface.ts b/apps/cs/src/entities/user/model/user-interface.ts index 52ef7f38d..d1f2c664f 100644 --- a/apps/cs/src/entities/user/model/user-interface.ts +++ b/apps/cs/src/entities/user/model/user-interface.ts @@ -98,8 +98,8 @@ export const userUpdateSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ) .optional(), roleId: z.string().optional(), @@ -127,8 +127,8 @@ export const userPasswordUpdateSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ) .optional(), provider: z.string().optional(), @@ -136,14 +136,14 @@ export const userPasswordUpdateSchema = z.object({ newPassword: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ), newPasswordConfirm: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ), }); @@ -153,8 +153,8 @@ export const userFindPasswordUpdateSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ), }); @@ -163,8 +163,8 @@ export const userPasswordMatchSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ), }); @@ -173,8 +173,8 @@ export const userUpdateInfoSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ) .optional(), provider: z.string().optional(), @@ -191,8 +191,8 @@ export const userSaveSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ), pswdHnt: z.string().max(10), pswdAns: z.string().max(100).optional(), diff --git a/apps/cs/src/pages/users/ui/agency-search-dialog.tsx b/apps/cs/src/pages/users/ui/agency-search-dialog.tsx new file mode 100644 index 000000000..1193c8504 --- /dev/null +++ b/apps/cs/src/pages/users/ui/agency-search-dialog.tsx @@ -0,0 +1,175 @@ +import { useState } from "react"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + type PaginationState, +} from "@tanstack/react-table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogTrigger, + DialogClose, + Button, + Input, + Label, + DataTable, +} from "@common/components"; +import { + LwpoCodeApi, + type LwpoCode, + type LwpoCodeParams, +} from "@/entities/code"; +import { ENDPOINT } from "@/shared/config"; + +interface AgencySearchDialogProps { + onSelectAgency: (data: { lwpoCd: string; lwpoCdNm: string }) => void; +} + +function AgencySearchDialog({ onSelectAgency }: AgencySearchDialogProps) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [selectedAgency, setSelectedAgency] = useState(null); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [searchQuery, setSearchQuery] = useState({ + lwpoCdNm: "", + }); + + const { data, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, pagination], + queryFn: () => + LwpoCodeApi.getLwpoCodeList({ + ...searchQuery, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + }); + + const table = useReactTable({ + data: data?.content ?? [], + columns: [ + { + accessorKey: "lwpoCd", + header: "기관코드", + }, + { + accessorKey: "lwpoCdNm", + header: "기관명", + }, + ], + state: { + pagination, + }, + rowCount: data?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: false, + getRowId: (row) => row.lwpoCd.toString(), + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + const handleSearch = async () => { + await refetch(); + queryClient.removeQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE], + }); + setPagination({ + pageIndex: 0, + pageSize: 10, + }); + table.setRowSelection({}); + setSelectedAgency(null); + }; + + const handleRowClick = (row: LwpoCode) => { + table.setRowSelection({ + [row.lwpoCd.toString()]: true, + }); + setSelectedAgency(row); + }; + + const handleSelectAgency = () => { + if (!selectedAgency) return; + onSelectAgency({ + lwpoCd: selectedAgency.lwpoCd, + lwpoCdNm: selectedAgency.lwpoCdNm, + }); + setOpen(false); + }; + + return ( + + + + + + + 기관 검색 + + +
{ + e.preventDefault(); + e.stopPropagation(); + handleSearch(); + }} + > +
+
+ + + setSearchQuery({ + ...searchQuery, + lwpoCdNm: e.target.value, + }) + } + placeholder="기관명을 입력하세요" + className="!text-[1.3rem]" + /> + +
+
+
+ + + + + + + + + +
+
+ ); +} + +export { AgencySearchDialog }; diff --git a/apps/cs/src/pages/users/ui/users-form.tsx b/apps/cs/src/pages/users/ui/users-form.tsx index fa9567936..9c7527f16 100644 --- a/apps/cs/src/pages/users/ui/users-form.tsx +++ b/apps/cs/src/pages/users/ui/users-form.tsx @@ -16,6 +16,7 @@ import { SelectValue, } from "@common/components"; import { FormWrapper } from "@/widgets/form-wrapper"; +import { CodeApi, CodeSelect } from "@/entities/code"; import { UserApi, type UserSaveData } from "@/entities/user"; import { FormField, @@ -23,10 +24,10 @@ import { FormControl, FormMessage, } from "@/shared/ui/form"; -import { useAlertStore, useMutationWrapper } from "@/shared/lib"; -import { CodeApi, CodeSelect } from "@/entities/code"; -import { ZipSearchDialog } from "./zip-search-dialog"; import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; +import { ZipSearchDialog } from "./zip-search-dialog"; +import { AgencySearchDialog } from "./agency-search-dialog"; type UsersFormProps = { userId?: string; @@ -38,9 +39,9 @@ type UsersFormProps = { function UsersForm({ userId, form, ...props }: UsersFormProps) { const isModifyMode = !!userId; - const [watchOgdpSeCd, watchDtlInstCd, watchLaeSeCd] = useWatch({ + const [watchOgdpSeCd, watchDtlInstCd] = useWatch({ control: form.control, - name: ["ogdpSeCd", "dtlInstCd", "laeSeCd"], + name: ["ogdpSeCd", "dtlInstCd"], }); const alert = useAlertStore((state) => state.setMessage); @@ -49,6 +50,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); const [passwordMatch, setPasswordMatch] = useState(true); + const [instSeCdNm, setInstSeCdNm] = useState(""); const { data: authSeCd } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.CODE, "AUTHSECD"], @@ -60,6 +62,30 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { queryFn: () => CodeApi.getCode("LAESECD"), }); + const { mutateAsync: checkDuplicateUserId } = useMutationWrapper({ + mutationFn: (userId: string) => UserApi.existsUserId({ userId }), + onSuccess: (duplicate) => { + if (!duplicate) { + alert("사용가능한 아이디입니다."); + setIsValidUserId(true); + return; + } + alert("중복된 아이디입니다."); + }, + }); + + const { mutateAsync: checkDuplicateEmail } = useMutationWrapper({ + mutationFn: (email: string) => UserApi.existsEmail({ email }), + onSuccess: (duplicate) => { + if (!duplicate) { + alert("사용가능한 이메일입니다."); + setIsValidEmail(true); + return; + } + alert("중복된 이메일입니다."); + }, + }); + const getAuthSeCdSelectData = () => { if (watchOgdpSeCd === "1") return authSeCd?.filter( @@ -91,29 +117,9 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { ); }; - const { mutateAsync: checkDuplicateUserId } = useMutationWrapper({ - mutationFn: (userId: string) => UserApi.existsUserId({ userId }), - onSuccess: (duplicate) => { - if (!duplicate) { - alert("사용가능한 아이디입니다."); - setIsValidUserId(true); - return; - } - alert("중복된 아이디입니다."); - }, - }); - - const { mutateAsync: checkDuplicateEmail } = useMutationWrapper({ - mutationFn: (email: string) => UserApi.existsEmail({ email }), - onSuccess: (duplicate) => { - if (!duplicate) { - alert("사용가능한 이메일입니다."); - setIsValidEmail(true); - return; - } - alert("중복된 이메일입니다."); - }, - }); + const getEnoCdSelectData = () => { + return laeSeCd?.filter((code) => code.unit?.startsWith("G2")); + }; const handlePasswordChange = (value: string) => { setPassword(value); @@ -138,6 +144,11 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { form.setValue("addr", data.addr); }; + const handleAgencySelect = (data: { lwpoCd: string; lwpoCdNm: string }) => { + form.setValue("instSeCd", data.lwpoCd); + setInstSeCdNm(data.lwpoCdNm); + }; + return (
@@ -587,7 +598,13 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { - + + {getEnoCdSelectData()?.map((code) => ( + + {code.lwpoCdNm} + + ))} + @@ -600,22 +617,14 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { 기관/업체명 - ( - - - - - - )} - /> +
+ + +
diff --git a/apps/cs/src/pages/users/ui/zip-search-dialog.tsx b/apps/cs/src/pages/users/ui/zip-search-dialog.tsx index be59c61ee..b8684f1b8 100644 --- a/apps/cs/src/pages/users/ui/zip-search-dialog.tsx +++ b/apps/cs/src/pages/users/ui/zip-search-dialog.tsx @@ -1,5 +1,9 @@ -import { useState, useRef, useCallback, useEffect } from "react"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useState, useRef, useEffect } from "react"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useReactTable, getCoreRowModel, @@ -27,6 +31,7 @@ interface ZipSearchDialogProps { } function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) { + const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [selectedAddress, setSelectedAddress] = useState(null); const [pagination, setPagination] = useState({ @@ -40,44 +45,18 @@ function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) { }); const hasFetchedOnce = useRef(false); - const fetchZipData = useCallback( - () => + const { data, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.ZIPS, pagination], + queryFn: () => ZipApi.searchZip({ ...searchQuery, page: pagination.pageIndex, size: pagination.pageSize, }), - [pagination.pageIndex, pagination.pageSize], - ); - - const { data, refetch } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.ZIPS, pagination], - queryFn: fetchZipData, placeholderData: keepPreviousData, enabled: false, }); - const handleSearch = async () => { - await refetch(); - setSelectedAddress(null); - hasFetchedOnce.current = true; - }; - - const handleRowClick = (row: Zip) => { - table.setRowSelection({ - [row.zipId.toString()]: true, - }); - setSelectedAddress(row); - }; - - const handleSelectAddress = () => { - onSelectAddress({ - zip: selectedAddress?.zoneNo ?? "", - addr: selectedAddress?.zipAddr ?? "", - }); - setOpen(false); - }; - const table = useReactTable({ data: data?.content ?? [], columns: [ @@ -102,6 +81,36 @@ function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) { getPaginationRowModel: getPaginationRowModel(), }); + const handleSearch = async () => { + await refetch(); + queryClient.removeQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.ZIPS], + }); + setPagination({ + pageIndex: 0, + pageSize: 10, + }); + table.setRowSelection({}); + setSelectedAddress(null); + hasFetchedOnce.current = true; + }; + + const handleRowClick = (row: Zip) => { + table.setRowSelection({ + [row.zipId.toString()]: true, + }); + setSelectedAddress(row); + }; + + const handleSelectAddress = () => { + if (!selectedAddress) return; + onSelectAddress({ + zip: selectedAddress.zoneNo, + addr: selectedAddress.zipAddr, + }); + setOpen(false); + }; + useEffect(() => { if (hasFetchedOnce.current) refetch(); }, [pagination, refetch]); @@ -181,7 +190,7 @@ function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) {
- +
From 0c658ee5dad9ce9bdc79dacf8d3fb56f115aab1b Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 13 Jun 2025 15:38:48 +0900 Subject: [PATCH 004/390] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/[userUnqId]/page.tsx | 10 ++ apps/cs/src/entities/user/index.ts | 4 + .../src/entities/user/model/user-interface.ts | 56 +++--- apps/cs/src/pages/users/index.ts | 1 + .../pages/users/ui/password-reset-dialog.tsx | 122 +++++++++++++ apps/cs/src/pages/users/ui/user-detail.tsx | 169 ++++++++++++++++++ apps/cs/src/pages/users/ui/users-form.tsx | 114 ++++++------ apps/cs/src/pages/users/ui/users-page.tsx | 10 +- 8 files changed, 400 insertions(+), 86 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx create mode 100644 apps/cs/src/pages/users/ui/password-reset-dialog.tsx create mode 100644 apps/cs/src/pages/users/ui/user-detail.tsx diff --git a/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx b/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx new file mode 100644 index 000000000..d4e0445ec --- /dev/null +++ b/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx @@ -0,0 +1,10 @@ +import { UserDetail as VUserDetail } from "@/pages/users"; + +export default async function UserDetail({ + params, +}: { + params: Promise<{ userUnqId: string }>; +}) { + const { userUnqId } = await params; + return ; +} diff --git a/apps/cs/src/entities/user/index.ts b/apps/cs/src/entities/user/index.ts index 947d7375b..bd3627f71 100644 --- a/apps/cs/src/entities/user/index.ts +++ b/apps/cs/src/entities/user/index.ts @@ -1,7 +1,11 @@ export { UserApi } from "./api/user-service"; export { + USER_STATE_CODE, userSaveSchema, + userPasswordUpdateSchema, type UserSearchParams, type UserListResponse, type UserSaveData, + type UserDetailResponse, + type UserPasswordUpdateData, } from "./model/user-interface"; diff --git a/apps/cs/src/entities/user/model/user-interface.ts b/apps/cs/src/entities/user/model/user-interface.ts index d1f2c664f..abc48f9ae 100644 --- a/apps/cs/src/entities/user/model/user-interface.ts +++ b/apps/cs/src/entities/user/model/user-interface.ts @@ -1,6 +1,16 @@ import { z } from "zod"; import type { PageableList, PageableParams } from "@/shared/model"; +export const USER_STATE_CODE = { + 대기: "00", + 정상: "01", + 정지: "07", + 탈퇴: "08", + 삭제: "09", +} as const; + +type UserStateCode = (typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE]; + // 사용자 검색 파라미터 export interface UserSearchParams extends PageableParams { keywordType?: string; @@ -19,7 +29,7 @@ export interface UserListResponse { email: string; roleId: string; roleName: string; - userStateCode: string; + userStateCode: UserStateCode; userStateCodeName: string; lastLoginDate: string; loginFailCount: number; @@ -49,7 +59,7 @@ export interface UserDetailResponse { email: string; encryptedPassword: string; refreshToken: string; - userStateCode: string; + userStateCode: UserStateCode; lastLoginDate: string; loginFailCount: number; pswdHnt: string; @@ -75,12 +85,14 @@ export interface UserDetailResponse { instSeCd: string; aprvYmd: string; enoCd: string; + dtlInstCd: string; laeSeNm: string; ogdpSeNm: string; instSeNm: string; userStateNm: string; pswdHntNm: string; ecoNm: string; + dtlInstNm: string; } // 사용자 상태 통계 응답 인터페이스 @@ -123,29 +135,23 @@ export const userUpdateSchema = z.object({ }); // 사용자 비밀번호 변경 스키마 -export const userPasswordUpdateSchema = z.object({ - password: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ) - .optional(), - provider: z.string().optional(), - token: z.string().optional(), - newPassword: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ), - newPasswordConfirm: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ), -}); +export const userPasswordUpdateSchema = z + .object({ + password: z.string().optional(), + provider: z.string().optional(), + token: z.string().optional(), + newPassword: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + newPasswordConfirm: z.string(), + }) + .refine((data) => data.newPassword === data.newPasswordConfirm, { + message: "비밀번호가 일치하지 않습니다.", + path: ["newPasswordConfirm"], + }); // 사용자 비밀번호 찾기 변경 스키마 export const userFindPasswordUpdateSchema = z.object({ diff --git a/apps/cs/src/pages/users/index.ts b/apps/cs/src/pages/users/index.ts index f0c736dea..e518fb440 100644 --- a/apps/cs/src/pages/users/index.ts +++ b/apps/cs/src/pages/users/index.ts @@ -1,2 +1,3 @@ export { UsersPage } from "./ui/users-page"; export { CreatePage } from "./ui/create-page"; +export { UserDetail } from "./ui/user-detail"; diff --git a/apps/cs/src/pages/users/ui/password-reset-dialog.tsx b/apps/cs/src/pages/users/ui/password-reset-dialog.tsx new file mode 100644 index 000000000..102358e55 --- /dev/null +++ b/apps/cs/src/pages/users/ui/password-reset-dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, + Button, + Input, + DialogTrigger, +} from "@common/components"; +import { + UserApi, + userPasswordUpdateSchema, + type UserPasswordUpdateData, +} from "@/entities/user"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shared/ui/form"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; + +interface PasswordResetDialogProps { + userUnqId: string; +} + +export function PasswordResetDialog({ userUnqId }: PasswordResetDialogProps) { + const alert = useAlertStore((state) => state.setMessage); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(userPasswordUpdateSchema), + defaultValues: { + newPassword: "", + newPasswordConfirm: "", + }, + }); + + const { mutate: onPasswordReset } = useMutationWrapper({ + mutationFn: (data: UserPasswordUpdateData) => + UserApi.initializePassword(userUnqId, data), + onSuccess: () => { + alert("비밀번호가 초기화되었습니다."); + setOpen(false); + form.reset(); + }, + }); + + return ( + + + + + + + 비밀번호 초기화 + +
+ onPasswordReset(data))} + className="space-y-6" + > + ( + + 새 비밀번호 + + + + + + )} + /> + ( + + 새 비밀번호 확인 + + + + + + )} + /> + + + + + + + + +
+
+ ); +} diff --git a/apps/cs/src/pages/users/ui/user-detail.tsx b/apps/cs/src/pages/users/ui/user-detail.tsx new file mode 100644 index 000000000..4513555c2 --- /dev/null +++ b/apps/cs/src/pages/users/ui/user-detail.tsx @@ -0,0 +1,169 @@ +"use client"; + +import dayjs from "dayjs"; +import { useRouter } from "next/navigation"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Button, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { USER_STATE_CODE, UserApi } 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(); + const queryClient = useQueryClient(); + const alert = useAlertStore((state) => state.setMessage); + + const { data } = useQuery({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + queryFn: () => UserApi.findByUserUnqId(userUnqId), + enabled: !!userUnqId, + }); + + const { mutate: onLockUser } = useMutationWrapper({ + mutationFn: () => UserApi.lockUser(userUnqId), + onSuccess: () => { + alert("계정 잠금이 활성화 되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + }); + }, + }); + + const { mutate: onUnlockUser } = useMutationWrapper({ + mutationFn: () => UserApi.unlockUser(userUnqId), + onSuccess: () => { + alert("계정 잠금이 해제되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + }); + }, + }); + + const { mutate: onApplyUser } = useMutationWrapper({ + mutationFn: () => UserApi.applyUser(userUnqId), + onSuccess: () => { + alert("사용자 승인이 완료되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + }); + }, + }); + + return ( +
+
+

회원 정보

+
+
+
+ {data?.userStateCode === USER_STATE_CODE.정지 ? ( + + ) : ( + + )} + + {data?.userStateCode === USER_STATE_CODE.대기 && ( + + )} +
+ + + + + + + + 아이디 + {data?.userId} + + + 이름 + {data?.userName} + + + 연락처 + 휴대폰: {data?.mphn} + + + 전화번호: {data?.telno} + + + 이메일 + {data?.email} + + + 주소 + + {data?.zip && `(${data?.zip})`} {data?.addr} + + + + {data?.daddr} + + + 기관구분 + {data?.ogdpSeNm} + + + 세부기관 + {data?.dtlInstNm} + + + 소속 + {data?.laeSeNm} + + + 환경청 + {data?.ecoNm} + + + 가입일 + + {data?.regDt ? dayjs(data?.regDt).format("YYYY-MM-DD") : ""} + + + + 최근로그인 + + {data?.lastLoginDate + ? dayjs(data?.lastLoginDate).format("YYYY-MM-DD HH:mm:ss") + : ""} + + + + 계정상태 + {data?.userStateNm} + + +
+
+
+ + + +
+
+ ); +} + +export { UserDetail }; diff --git a/apps/cs/src/pages/users/ui/users-form.tsx b/apps/cs/src/pages/users/ui/users-form.tsx index 9c7527f16..ae575f317 100644 --- a/apps/cs/src/pages/users/ui/users-form.tsx +++ b/apps/cs/src/pages/users/ui/users-form.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo, useCallback } from "react"; import { useWatch, type UseFormReturn } from "react-hook-form"; import { useQuery } from "@tanstack/react-query"; import { @@ -47,8 +47,6 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { const [isValidUserId, setIsValidUserId] = useState(false); const [isValidEmail, setIsValidEmail] = useState(false); - const [password, setPassword] = useState(""); - const [passwordConfirm, setPasswordConfirm] = useState(""); const [passwordMatch, setPasswordMatch] = useState(true); const [instSeCdNm, setInstSeCdNm] = useState(""); @@ -86,68 +84,73 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { }, }); - const getAuthSeCdSelectData = () => { + const authSeCdSelectData = useMemo(() => { if (watchOgdpSeCd === "1") - return authSeCd?.filter( - (code) => - code.lwpoCd === "ROLE_USER1" || - code.lwpoCd === "ROLE_USER2" || - code.lwpoCd === "ROLE_USER3", + return ( + authSeCd?.filter( + (code) => + code.lwpoCd === "ROLE_USER1" || + code.lwpoCd === "ROLE_USER2" || + code.lwpoCd === "ROLE_USER3", + ) ?? [] ); if (watchOgdpSeCd === "3" || watchOgdpSeCd === "4") - return authSeCd?.filter( - (code) => code.lwpoCd === "ROLE_USER4" || code.lwpoCd === "ROLE_USER5", + return ( + authSeCd?.filter( + (code) => + code.lwpoCd === "ROLE_USER4" || code.lwpoCd === "ROLE_USER5", + ) ?? [] ); - }; + return []; + }, [authSeCd, watchOgdpSeCd]); - const getLaeSeCdSelectData = () => { + const laeSeCdSelectData = useMemo(() => { if (watchDtlInstCd === "ROLE_USER1") - return laeSeCd?.filter((code) => code.lwpoCd === "G102"); + return laeSeCd?.filter((code) => code.lwpoCd === "G102") ?? []; if (watchDtlInstCd === "ROLE_USER2") - return laeSeCd?.filter((code) => code.lwpoCd === "G101"); + return laeSeCd?.filter((code) => code.lwpoCd === "G101") ?? []; if (watchDtlInstCd === "ROLE_USER3") - return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2")); + return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2")) ?? []; if (watchDtlInstCd === "ROLE_USER4") - return laeSeCd?.filter( - (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4, + return ( + laeSeCd?.filter( + (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4, + ) ?? [] ); if (watchDtlInstCd === "ROLE_USER5") - return laeSeCd?.filter( - (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 7, + return ( + laeSeCd?.filter( + (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 7, + ) ?? [] ); - }; + return []; + }, [laeSeCd, watchDtlInstCd]); - const getEnoCdSelectData = () => { - return laeSeCd?.filter((code) => code.unit?.startsWith("G2")); - }; + const enoCdSelectData = useMemo( + () => laeSeCd?.filter((code) => code.unit?.startsWith("G2")) ?? [], + [laeSeCd], + ); - const handlePasswordChange = (value: string) => { - setPassword(value); - if (passwordConfirm && value !== passwordConfirm) { - setPasswordMatch(false); - } else { - setPasswordMatch(true); - } - }; + const handlePasswordConfirmChange = useCallback((value: string) => { + const password = form.getValues("password"); + setPasswordMatch(!password || value === password); + }, []); - const handlePasswordConfirmChange = (value: string) => { - setPasswordConfirm(value); - if (password && value !== password) { - setPasswordMatch(false); - } else { - setPasswordMatch(true); - } - }; + const handleAddressSelect = useCallback( + (data: { zip: string; addr: string }) => { + form.setValue("zip", data.zip); + form.setValue("addr", data.addr); + }, + [form], + ); - const handleAddressSelect = (data: { zip: string; addr: string }) => { - form.setValue("zip", data.zip); - form.setValue("addr", data.addr); - }; - - const handleAgencySelect = (data: { lwpoCd: string; lwpoCdNm: string }) => { - form.setValue("instSeCd", data.lwpoCd); - setInstSeCdNm(data.lwpoCdNm); - }; + const handleAgencySelect = useCallback( + (data: { lwpoCd: string; lwpoCdNm: string }) => { + form.setValue("instSeCd", data.lwpoCd); + setInstSeCdNm(data.lwpoCdNm); + }, + [form], + ); return ( @@ -216,10 +219,6 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { {...field} type="password" value={field.value ?? ""} - onChange={(e) => { - field.onChange(e); - handlePasswordChange(e.target.value); - }} className="w-[24rem] !text-[1.3rem]" placeholder="비밀번호를 입력하세요" /> @@ -238,14 +237,13 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
handlePasswordConfirmChange(e.target.value) } className="w-[24rem] !text-[1.3rem]" placeholder="비밀번호를 다시 입력하세요" /> - {!passwordMatch && passwordConfirm && ( + {!passwordMatch && (

비밀번호가 일치하지 않습니다.

@@ -526,7 +524,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { - {getAuthSeCdSelectData()?.map((code) => ( + {authSeCdSelectData?.map((code) => ( {code.lwpoCdNm} @@ -561,7 +559,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { - {getLaeSeCdSelectData()?.map((code) => ( + {laeSeCdSelectData?.map((code) => ( {code.lwpoCdNm} @@ -599,7 +597,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { - {getEnoCdSelectData()?.map((code) => ( + {enoCdSelectData?.map((code) => ( {code.lwpoCdNm} diff --git a/apps/cs/src/pages/users/ui/users-page.tsx b/apps/cs/src/pages/users/ui/users-page.tsx index 434dd7e7c..956a3434c 100644 --- a/apps/cs/src/pages/users/ui/users-page.tsx +++ b/apps/cs/src/pages/users/ui/users-page.tsx @@ -24,7 +24,11 @@ import { SelectValue, } from "@common/components"; import { CodeApi } from "@/entities/code"; -import { UserApi, type UserSearchParams } from "@/entities/user"; +import { + UserApi, + type UserListResponse, + type UserSearchParams, +} from "@/entities/user"; import { ENDPOINT } from "@/shared/config"; import { columns } from "../model/table-columns"; @@ -92,8 +96,8 @@ function UsersPage() { refetch(); }; - const handleRowClick = (row: unknown) => { - console.log(row); + const handleRowClick = (row: UserListResponse) => { + router.push(`/users/${row.userUnqId}`); }; const handlePresetDateRange = (value: string) => { From 4b7d4f0cc9355825f11b547d01fef8f7a049495a Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 13 Jun 2025 17:37:13 +0900 Subject: [PATCH 005/390] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=ED=8F=BC=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/[userUnqId]/modify/page.tsx | 10 ++ .../users/[userUnqId]/page.tsx | 2 +- apps/cs/src/entities/user/index.ts | 2 + apps/cs/src/pages/users/ui/create-page.tsx | 11 +- apps/cs/src/pages/users/ui/modify-page.tsx | 75 +++++++++++++ apps/cs/src/pages/users/ui/user-detail.tsx | 7 +- apps/cs/src/pages/users/ui/users-form.tsx | 102 ++++++++++++------ apps/cs/src/widgets/post/ui/post-form.tsx | 40 ++++--- 8 files changed, 180 insertions(+), 69 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/users/[userUnqId]/modify/page.tsx create mode 100644 apps/cs/src/pages/users/ui/modify-page.tsx diff --git a/apps/cs/app/(with-base-layout)/users/[userUnqId]/modify/page.tsx b/apps/cs/app/(with-base-layout)/users/[userUnqId]/modify/page.tsx new file mode 100644 index 000000000..a01387c44 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/users/[userUnqId]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/users/ui/modify-page"; + +export default async function UserModifyPage({ + params, +}: { + params: Promise<{ userUnqId: string }>; +}) { + const { userUnqId } = await params; + return ; +} diff --git a/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx b/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx index d4e0445ec..da4e79b00 100644 --- a/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx +++ b/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx @@ -1,6 +1,6 @@ import { UserDetail as VUserDetail } from "@/pages/users"; -export default async function UserDetail({ +export default async function UserDetailPage({ params, }: { params: Promise<{ userUnqId: string }>; diff --git a/apps/cs/src/entities/user/index.ts b/apps/cs/src/entities/user/index.ts index bd3627f71..7240c1424 100644 --- a/apps/cs/src/entities/user/index.ts +++ b/apps/cs/src/entities/user/index.ts @@ -3,9 +3,11 @@ export { USER_STATE_CODE, userSaveSchema, userPasswordUpdateSchema, + userUpdateSchema, type UserSearchParams, type UserListResponse, type UserSaveData, type UserDetailResponse, type UserPasswordUpdateData, + type UserUpdateData, } from "./model/user-interface"; diff --git a/apps/cs/src/pages/users/ui/create-page.tsx b/apps/cs/src/pages/users/ui/create-page.tsx index c91fe115d..84cc23b06 100644 --- a/apps/cs/src/pages/users/ui/create-page.tsx +++ b/apps/cs/src/pages/users/ui/create-page.tsx @@ -42,21 +42,12 @@ function CreatePage() { }, }); - // 저장 처리 함수 - const handleSave = async (data: UserSaveData) => { - try { - await saveUser(data); - } catch (error) { - console.error("회원 등록 실패:", error); - } - }; - return (

회원 등록

- +
); } diff --git a/apps/cs/src/pages/users/ui/modify-page.tsx b/apps/cs/src/pages/users/ui/modify-page.tsx new file mode 100644 index 000000000..fc93e4b05 --- /dev/null +++ b/apps/cs/src/pages/users/ui/modify-page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { + UserApi, + userUpdateSchema, + type UserUpdateData, +} from "@/entities/user"; +import { ENDPOINT } from "@/shared/config"; +import { useMutationWrapper, useAlertStore } from "@/shared/lib"; +import { UsersForm } from "./users-form"; + +interface ModifyPageProps { + userUnqId: string; +} + +export function ModifyPage({ userUnqId }: ModifyPageProps) { + const router = useRouter(); + const alert = useAlertStore((state) => state.setMessage); + + const { data: userData } = useQuery({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + queryFn: () => UserApi.findByUserUnqId(userUnqId), + enabled: !!userUnqId, + }); + + const form = useForm({ + resolver: zodResolver(userUpdateSchema), + defaultValues: { + userId: userData?.userId ?? "", + userName: userData?.userName ?? "", + email: userData?.email ?? "", + userStateCode: userData?.userStateCode ?? "", + pswdHnt: userData?.pswdHnt ?? "", + pswdAns: userData?.pswdAns ?? "", + mphn: userData?.mphn ?? "", + telno: userData?.telno ?? "", + zip: userData?.zip ?? "", + addr: userData?.addr ?? "", + daddr: userData?.daddr ?? "", + laeSeCd: userData?.laeSeCd ?? "", + ogdpSeCd: userData?.ogdpSeCd ?? "", + aprvYn: userData?.aprvYn ?? "", + expryYmd: userData?.expryYmd ?? "", + instSeCd: userData?.instSeCd ?? "", + dtlInstCd: userData?.dtlInstCd ?? "", + aprvYmd: userData?.aprvYmd ?? "", + enoCd: userData?.enoCd ?? "", + }, + }); + + const { mutateAsync: updateUser } = useMutationWrapper({ + mutationFn: (data: UserUpdateData) => UserApi.updateUser(userUnqId, data), + onSuccess: () => { + alert("회원 정보가 수정되었습니다.", router.back); + }, + }); + + return ( +
+
+

회원 정보 수정

+
+ +
+ ); +} diff --git a/apps/cs/src/pages/users/ui/user-detail.tsx b/apps/cs/src/pages/users/ui/user-detail.tsx index 4513555c2..9673f5c02 100644 --- a/apps/cs/src/pages/users/ui/user-detail.tsx +++ b/apps/cs/src/pages/users/ui/user-detail.tsx @@ -1,7 +1,7 @@ "use client"; import dayjs from "dayjs"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, @@ -18,6 +18,7 @@ import { PasswordResetDialog } from "./password-reset-dialog"; function UserDetail({ userUnqId }: { userUnqId: string }) { const router = useRouter(); + const pathname = usePathname(); const queryClient = useQueryClient(); const alert = useAlertStore((state) => state.setMessage); @@ -157,7 +158,9 @@ function UserDetail({ userUnqId }: { userUnqId: string }) { - + diff --git a/apps/cs/src/pages/users/ui/users-form.tsx b/apps/cs/src/pages/users/ui/users-form.tsx index ae575f317..d69e918b0 100644 --- a/apps/cs/src/pages/users/ui/users-form.tsx +++ b/apps/cs/src/pages/users/ui/users-form.tsx @@ -1,5 +1,10 @@ import { useState, useMemo, useCallback } from "react"; -import { useWatch, type UseFormReturn } from "react-hook-form"; +import { + useWatch, + type Path, + type PathValue, + type UseFormReturn, +} from "react-hook-form"; import { useQuery } from "@tanstack/react-query"; import { Button, @@ -17,7 +22,11 @@ import { } from "@common/components"; import { FormWrapper } from "@/widgets/form-wrapper"; import { CodeApi, CodeSelect } from "@/entities/code"; -import { UserApi, type UserSaveData } from "@/entities/user"; +import { + UserApi, + type UserSaveData, + type UserUpdateData, +} from "@/entities/user"; import { FormField, FormItem, @@ -29,19 +38,23 @@ import { useAlertStore, useMutationWrapper } from "@/shared/lib"; import { ZipSearchDialog } from "./zip-search-dialog"; import { AgencySearchDialog } from "./agency-search-dialog"; -type UsersFormProps = { +type UsersFormProps = { userId?: string; - form: UseFormReturn; - handleSave: (data: UserSaveData) => Promise; + form: UseFormReturn; + handleSave: (data: T) => Promise; handleDelete?: () => Promise; }; -function UsersForm({ userId, form, ...props }: UsersFormProps) { +function UsersForm({ + userId, + form, + ...props +}: UsersFormProps) { const isModifyMode = !!userId; const [watchOgdpSeCd, watchDtlInstCd] = useWatch({ control: form.control, - name: ["ogdpSeCd", "dtlInstCd"], + name: ["ogdpSeCd" as Path, "dtlInstCd" as Path], }); const alert = useAlertStore((state) => state.setMessage); @@ -132,21 +145,24 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { ); const handlePasswordConfirmChange = useCallback((value: string) => { - const password = form.getValues("password"); + const password = form.getValues("password" as Path); setPasswordMatch(!password || value === password); }, []); const handleAddressSelect = useCallback( (data: { zip: string; addr: string }) => { - form.setValue("zip", data.zip); - form.setValue("addr", data.addr); + form.setValue("zip" as Path, data.zip as PathValue>); + form.setValue("addr" as Path, data.addr as PathValue>); }, [form], ); const handleAgencySelect = useCallback( (data: { lwpoCd: string; lwpoCdNm: string }) => { - form.setValue("instSeCd", data.lwpoCd); + form.setValue( + "instSeCd" as Path, + data.lwpoCd as PathValue>, + ); setInstSeCdNm(data.lwpoCdNm); }, [form], @@ -167,7 +183,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => (
@@ -191,7 +207,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { className="px-6" onClick={(e) => { e.preventDefault(); - checkDuplicateUserId(field.value); + checkDuplicateUserId(field.value as string); }} disabled={!field.value || isValidUserId} > @@ -211,7 +227,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => ( @@ -258,7 +274,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => ( @@ -281,7 +297,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => ( @@ -304,7 +320,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => ( @@ -327,7 +343,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => ( @@ -347,7 +363,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => ( @@ -370,7 +386,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => (
@@ -393,7 +409,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { onClick={(e) => { e.preventDefault(); if (field.value) { - checkDuplicateEmail(field.value); + checkDuplicateEmail(field.value as string); } }} disabled={!field.value || isValidEmail} @@ -415,7 +431,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
} render={({ field }) => ( @@ -433,7 +449,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
} render={({ field }) => ( @@ -454,7 +470,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => ( @@ -477,7 +493,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => ( @@ -487,9 +503,18 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { value={field.value ?? ""} onValueChange={(value) => { field.onChange(value); - form.setValue("dtlInstCd", ""); - form.setValue("laeSeCd", ""); - form.setValue("enoCd", ""); + form.setValue( + "dtlInstCd" as Path, + "" as PathValue>, + ); + form.setValue( + "laeSeCd" as Path, + "" as PathValue>, + ); + form.setValue( + "enoCd" as Path, + "" as PathValue>, + ); }} className="w-[16rem]" placeholder="기관 선택" @@ -508,7 +533,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => ( @@ -516,8 +541,14 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { value={field.value ?? ""} onValueChange={(value) => { field.onChange(value); - form.setValue("laeSeCd", ""); - form.setValue("enoCd", ""); + form.setValue( + "laeSeCd" as Path, + "" as PathValue>, + ); + form.setValue( + "enoCd" as Path, + "" as PathValue>, + ); }} > @@ -544,7 +575,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => ( @@ -552,7 +583,10 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { value={field.value ?? ""} onValueChange={(value) => { field.onChange(value); - form.setValue("enoCd", ""); + form.setValue( + "enoCd" as Path, + "" as PathValue>, + ); }} > @@ -579,7 +613,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { } render={({ field }) => ( diff --git a/apps/cs/src/widgets/post/ui/post-form.tsx b/apps/cs/src/widgets/post/ui/post-form.tsx index dc38f327d..55cd85657 100644 --- a/apps/cs/src/widgets/post/ui/post-form.tsx +++ b/apps/cs/src/widgets/post/ui/post-form.tsx @@ -1,7 +1,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import type { UseFormReturn, FieldPath } from "react-hook-form"; +import type { UseFormReturn, Path } from "react-hook-form"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Input, @@ -35,7 +35,7 @@ function PostForm({ form, handleSave, }: PostFormProps) { - const atflCd = form.getValues("atflCd" as FieldPath); + const atflCd = form.getValues("atflCd" as Path); const router = useRouter(); const { confirm } = useConfirm(); @@ -81,7 +81,7 @@ function PostForm({ } + name={"pstTtl" as Path} render={({ field }) => ( @@ -102,7 +102,7 @@ function PostForm({ } + name={"wrtrNm" as Path} render={({ field }) => ( @@ -124,7 +124,7 @@ function PostForm({ } + name={"pstPswd" as Path} render={({ field }) => ( @@ -147,7 +147,7 @@ function PostForm({ } + name={"pstCn" as Path} render={({ field }) => ( @@ -170,7 +170,7 @@ function PostForm({ } + name={"rfrncUrl" as Path} render={({ field }) => ( @@ -194,7 +194,7 @@ function PostForm({
} + name={"popupYn" as Path} render={({ field }) => ( @@ -218,7 +218,7 @@ function PostForm({ /> } + name={"popupYnEtc" as Path} render={({ field }) => { const fieldValues = form.getValues().popupYnEtc?.[0]; return ( @@ -227,9 +227,7 @@ function PostForm({ , - ) === "N" + form.getValues("popupYn" as Path) === "N" } handlePeriodValueChange={( periodCode, @@ -267,7 +265,7 @@ function PostForm({
} + name={"upndFixYn" as Path} render={({ field }) => ( @@ -291,7 +289,7 @@ function PostForm({ /> } + name={"upndFixYnEtc" as Path} render={({ field }) => { const fieldValues = form.getValues().upndFixYnEtc?.[0]; @@ -301,9 +299,8 @@ function PostForm({ , - ) === "N" + form.getValues("upndFixYn" as Path) === + "N" } handlePeriodValueChange={( periodCode, @@ -341,7 +338,7 @@ function PostForm({
} + name={"prdPstgYn" as Path} render={({ field }) => ( @@ -365,7 +362,7 @@ function PostForm({ /> } + name={"prdPstgYnEtc" as Path} render={({ field }) => { const fieldValues = form.getValues().prdPstgYnEtc?.[0]; @@ -375,9 +372,8 @@ function PostForm({ , - ) === "N" + form.getValues("prdPstgYn" as Path) === + "N" } handlePeriodValueChange={( periodCode, From ef7310623c87c6b66ae78fc6202b20ff8457eb27 Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 13 Jun 2025 18:06:16 +0900 Subject: [PATCH 006/390] =?UTF-8?q?chore:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=B4=88=EA=B8=B0=ED=99=94=20=ED=8F=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/src/pages/users/ui/password-reset-dialog.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cs/src/pages/users/ui/password-reset-dialog.tsx b/apps/cs/src/pages/users/ui/password-reset-dialog.tsx index 102358e55..e94d1cb1d 100644 --- a/apps/cs/src/pages/users/ui/password-reset-dialog.tsx +++ b/apps/cs/src/pages/users/ui/password-reset-dialog.tsx @@ -43,6 +43,7 @@ export function PasswordResetDialog({ userUnqId }: PasswordResetDialogProps) { defaultValues: { newPassword: "", newPasswordConfirm: "", + provider: "password", }, }); From 3ae456515acfc484be8df33790120bc82eb2d836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A7=80=EC=B2=A0?= Date: Fri, 13 Jun 2025 18:09:13 +0900 Subject: [PATCH 007/390] =?UTF-8?q?favicon=20=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/public/favicon.ico | Bin 0 -> 4286 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/cs/public/favicon.ico diff --git a/apps/cs/public/favicon.ico b/apps/cs/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d304b69814b2d79060a24f36f667a7f53b768103 GIT binary patch literal 4286 zcmb`K3s6*59LCQ|ikK6XsZsfun##0v95pR15s|b|G%-a`$DE!vmSyIHWMNFiN+Xg- zJ|hw%MPPwl7SPpQ9=pp!V|wiw5)a-aYr6|L^?2bA>?& z4ZjBt6!KrO`(7dX2q6X$4he4}#~ok2?iNDQ*{|$R#ginMY$Exjid2&tQcm`gRm6up z(AoOV=Cjs|WDBV!aDmI5Q$#)@4?C-St8uRFOQK05amA}Pr;=+I+{$(1bFLXn%I?6c z+)F#_Y$iQ!^r(I2yeZ@qQP%iB?o(LvJ~gLfjL%1tKW^cc{m5MRk*y?=7|0oNt9>SO zPEUtVdF8klskljvb9}ag$oH`q=|(ifjdUji$wFc#H`_;)(kt&dzYCG?xU+ROKJ&hD zo!V#4^B^CR2BKE4jpGxQYx&umv^be>HONgkiwBkS-g^GhOXdoAR6 zr`?YIaqUc^)|dPlG2@zlFz?m&+060h9kp)v${L>&wcftxcvpR>c-zVI_}g_V*Rl2^ zqhaYAxvD%y5^VH z{VN65eLFgGPh{WUl>O^CUY78Xp<(|Rx6Yf?SNxtLM&w0^OPPvuKJbaa%0$0}#OR^5 z#ueB5&!GRv_Wpmil}wzhUafPZ^{GO0Iz?=u9}d6FJC|p^CcYo1gaVobmeXy1ilu{c*Ls#1-ZbzE>R{Bi-p*56m(wUc00yp;#Bz z6CG!;MnK1Brl_7cXBmr@!e`NJ9%5}i*gx`|$zw{>e(_XOp6GoxSGb?e5&xV|W47qp zkSTi9>qNi$3^A%cO)RKO6FcZX)tDx5Y%AT0>`m-9=IcT5E{Iq>_veDGUnR{%{>K6` z7r95tZ@Oo2(K-grhX#Xw(Ek_xjr1R*-$uWM=o|GSm;Pe=n=?? z_QDy17p|BzXw7Hl%)gQOH!^=o1M_cS{`Jhif%#vgyM^apwcP_hL_UP|@nf(^_a-J5 zM&SjzCtB9R#~hJLzq`XPuOT&t^}*Fym|LyGtfK}@KbD8U$~4R~e}iQQ!;oAYSoDj@ zYvZL{5qHTb_O=>C8p*hrO|nGVUpkT8k|8!07@pd;EFv?F#0QG|4RR> zhm$d*d>3YveUIs7-(h;`PE0G^j%g)ZG1dMp{B3b~*%pf_*3HiR->`p|{sx#`mGLWU>9;ZeHW&P7c>d`;{~-EZ%G;S+IZl5c`d_5~ zedZq@P`Wdl`R6kKT;{)*`LAdG^O^rp`nxOF-s~Pt0|2cX+J(SnA;z}~F`!8Z&tE;8 z+)d*zyPLw=-L!yPIG`cnZdW Date: Mon, 16 Jun 2025 11:08:18 +0900 Subject: [PATCH 008/390] =?UTF-8?q?feat:=20=EA=B7=B8=EB=A3=B9=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/groups/api/group-users-service.ts | 69 +++++++++++ .../cs/src/pages/groups/api/groups-service.ts | 58 ++++++++++ apps/cs/src/pages/groups/api/index.ts | 2 + .../groups/model/group-users-interface.ts | 107 ++++++++++++++++++ .../pages/groups/model/groups-interface.ts | 101 +++++++++++++++++ apps/cs/src/pages/groups/model/index.ts | 2 + apps/cs/src/shared/config/api.ts | 2 + 7 files changed, 341 insertions(+) create mode 100644 apps/cs/src/pages/groups/api/group-users-service.ts create mode 100644 apps/cs/src/pages/groups/api/groups-service.ts create mode 100644 apps/cs/src/pages/groups/api/index.ts create mode 100644 apps/cs/src/pages/groups/model/group-users-interface.ts create mode 100644 apps/cs/src/pages/groups/model/groups-interface.ts create mode 100644 apps/cs/src/pages/groups/model/index.ts diff --git a/apps/cs/src/pages/groups/api/group-users-service.ts b/apps/cs/src/pages/groups/api/group-users-service.ts new file mode 100644 index 000000000..69a81e366 --- /dev/null +++ b/apps/cs/src/pages/groups/api/group-users-service.ts @@ -0,0 +1,69 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + GroupUserService, + GroupUserSearchParams, + GroupUserList, + GroupUserDetailResponse, + GroupUserSaveData, + GroupUserUpdateData, +} from "../model/group-users-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( + grpSn: number, + userUnqId: string, + ): Promise { + const response = await client.delete( + `${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${grpSn}/${userUnqId}`, + ); + return response.data; + } +} diff --git a/apps/cs/src/pages/groups/api/groups-service.ts b/apps/cs/src/pages/groups/api/groups-service.ts new file mode 100644 index 000000000..2b570c3cb --- /dev/null +++ b/apps/cs/src/pages/groups/api/groups-service.ts @@ -0,0 +1,58 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + GroupService, + GroupSearchParams, + GroupList, + GroupDetailResponse, + GroupSaveData, + GroupUpdateData, +} from "../model/groups-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 findById(id: number): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.GROUPS}/${id}`, + ); + return response.data; + } + + // 그룹 저장 + public async saveGroup(data: GroupSaveData): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.GROUPS}`, + data, + ); + return response.data; + } + + // 그룹 수정 + public async updateGroup( + id: number, + data: GroupUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.GROUPS}/${id}`, + data, + ); + return response.data; + } + + // 그룹 삭제 + public async deleteGroup(id: number): Promise { + const response = await client.delete( + `${ENDPOINT.CMS_SERVICE.GROUPS}/${id}`, + ); + return response.data; + } +} diff --git a/apps/cs/src/pages/groups/api/index.ts b/apps/cs/src/pages/groups/api/index.ts new file mode 100644 index 000000000..3d10f342e --- /dev/null +++ b/apps/cs/src/pages/groups/api/index.ts @@ -0,0 +1,2 @@ +export * from "./groups-service"; +export * from "./group-users-service"; diff --git a/apps/cs/src/pages/groups/model/group-users-interface.ts b/apps/cs/src/pages/groups/model/group-users-interface.ts new file mode 100644 index 000000000..e26f1fa09 --- /dev/null +++ b/apps/cs/src/pages/groups/model/group-users-interface.ts @@ -0,0 +1,107 @@ +// 그룹 사용자 검색 파라미터 인터페이스 +export interface GroupUserSearchParams { + page?: number; + size?: number; + sort?: string; + grpSn?: number; + userId?: string; + userName?: string; +} + +// 그룹 사용자 목록 응답 인터페이스 +export interface GroupUserListResponseDto { + 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 GroupUserList { + content: GroupUserListResponseDto[]; + pageable: { + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + offset: number; + pageSize: number; + pageNumber: number; + paged: boolean; + unpaged: boolean; + }; + last: boolean; + totalPages: number; + totalElements: number; + size: number; + number: number; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + first: boolean; + numberOfElements: number; + empty: boolean; +} + +// 그룹 사용자 상세 응답 인터페이스 +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 { + grpSn: number; + 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: (grpSn: number, userUnqId: string) => Promise; +} diff --git a/apps/cs/src/pages/groups/model/groups-interface.ts b/apps/cs/src/pages/groups/model/groups-interface.ts new file mode 100644 index 000000000..c8d2c35fb --- /dev/null +++ b/apps/cs/src/pages/groups/model/groups-interface.ts @@ -0,0 +1,101 @@ +// 그룹 검색 파라미터 인터페이스 +export interface GroupSearchParams { + page?: number; + size?: number; + sort?: string; + grpNm?: string; + useYn?: string; +} + +// 그룹 목록 응답 인터페이스 +export interface GroupListResponseDto { + grpSn: number; + grpNm: string; + grpExpln: string; + useYn: string; + regDt: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; + rgtrNm: string; + mdfrNm: string; +} + +// 그룹 페이지 응답 인터페이스 +export interface GroupList { + content: GroupListResponseDto[]; + pageable: { + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + offset: number; + pageSize: number; + pageNumber: number; + paged: boolean; + unpaged: boolean; + }; + last: boolean; + totalPages: number; + totalElements: number; + size: number; + number: number; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + first: boolean; + numberOfElements: number; + empty: boolean; +} + +// 그룹 상세 응답 인터페이스 +export interface GroupDetailResponse { + grpSn: number; + grpNm: string; + grpExpln: string; + useYn: string; + regDt: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; + rgtrNm: string; + mdfrNm: string; +} + +// 그룹 저장 데이터 인터페이스 +export interface GroupSaveData { + grpNm: string; + grpExpln: string; + useYn: string; +} + +// 그룹 수정 데이터 인터페이스 +export interface GroupUpdateData { + grpNm: string; + grpExpln: string; + useYn: string; +} + +// 그룹 서비스 인터페이스 +export interface GroupService { + // 그룹 목록 조회 + findPageGroup: (params: GroupSearchParams) => Promise; + + // 그룹 단건 조회 + findById: (id: number) => Promise; + + // 그룹 저장 + saveGroup: (data: GroupSaveData) => Promise; + + // 그룹 수정 + updateGroup: ( + id: number, + data: GroupUpdateData, + ) => Promise; + + // 그룹 삭제 + deleteGroup: (id: number) => Promise; +} diff --git a/apps/cs/src/pages/groups/model/index.ts b/apps/cs/src/pages/groups/model/index.ts new file mode 100644 index 000000000..0852eb491 --- /dev/null +++ b/apps/cs/src/pages/groups/model/index.ts @@ -0,0 +1,2 @@ +export * from "./groups-interface"; +export * from "./group-users-interface"; diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index da5edaea1..54d038dbf 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -45,6 +45,8 @@ export const ENDPOINT = { SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem", SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl", ZIPS: "/cms-service/api/v1/zips", + GROUPS: "/cms-service/api/v1/groups", + GROUP_USERS: "/cms-service/api/v1/group-users", }, BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", From 318bae3a1e417d9ae268d3cf7a9ddb8c5efcc917 Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 17 Jun 2025 11:06:33 +0900 Subject: [PATCH 009/390] =?UTF-8?q?feat:=20=EA=B7=B8=EB=A3=B9=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cs/app/(with-base-layout)/groups/page.tsx | 2 + .../api/menu-authority-service.ts | 10 + apps/cs/src/entities/menu-authority/index.ts | 7 + .../model/menu-authority-interface.ts | 3 + .../role/api/role-service.ts | 2 +- apps/cs/src/entities/role/index.ts | 8 + .../role/model/role-interface.ts | 2 +- apps/cs/src/entities/site/ui/site-select.tsx | 5 +- .../pages/groups/api/group-users-service.ts | 6 +- .../cs/src/pages/groups/api/groups-service.ts | 21 +- apps/cs/src/pages/groups/api/index.ts | 2 - apps/cs/src/pages/groups/index.ts | 1 + .../group-menu-authority-table-columns.tsx | 117 ++++++++++ .../groups/model/group-users-interface.ts | 13 +- .../model/group-users-table-columns.tsx | 57 +++++ .../pages/groups/model/groups-interface.ts | 105 ++++----- .../groups/model/groups-table-columns.tsx | 27 +++ apps/cs/src/pages/groups/model/index.ts | 2 - apps/cs/src/pages/groups/ui/group-form.tsx | 204 ++++++++++++++++++ .../pages/groups/ui/group-users-section.tsx | 92 ++++++++ apps/cs/src/pages/groups/ui/groups-dialog.tsx | 112 ++++++++++ apps/cs/src/pages/groups/ui/groups-page.tsx | 18 ++ .../cs/src/pages/groups/ui/groups-section.tsx | 68 ++++++ apps/cs/src/pages/groups/ui/index.ts | 1 + .../pages/groups/ui/menu-authority-dialog.tsx | 110 ++++++++++ apps/cs/src/pages/groups/ui/role-select.tsx | 41 ++++ apps/cs/src/pages/role/ui/create-page.tsx | 3 +- apps/cs/src/pages/role/ui/modify-page.tsx | 3 +- apps/cs/src/pages/role/ui/role-form.tsx | 3 +- apps/cs/src/pages/role/ui/role-page.tsx | 3 +- 30 files changed, 952 insertions(+), 96 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/groups/page.tsx rename apps/cs/src/{pages => entities}/role/api/role-service.ts (95%) create mode 100644 apps/cs/src/entities/role/index.ts rename apps/cs/src/{pages => entities}/role/model/role-interface.ts (94%) delete mode 100644 apps/cs/src/pages/groups/api/index.ts create mode 100644 apps/cs/src/pages/groups/index.ts create mode 100644 apps/cs/src/pages/groups/model/group-menu-authority-table-columns.tsx create mode 100644 apps/cs/src/pages/groups/model/group-users-table-columns.tsx create mode 100644 apps/cs/src/pages/groups/model/groups-table-columns.tsx delete mode 100644 apps/cs/src/pages/groups/model/index.ts create mode 100644 apps/cs/src/pages/groups/ui/group-form.tsx create mode 100644 apps/cs/src/pages/groups/ui/group-users-section.tsx create mode 100644 apps/cs/src/pages/groups/ui/groups-dialog.tsx create mode 100644 apps/cs/src/pages/groups/ui/groups-page.tsx create mode 100644 apps/cs/src/pages/groups/ui/groups-section.tsx create mode 100644 apps/cs/src/pages/groups/ui/index.ts create mode 100644 apps/cs/src/pages/groups/ui/menu-authority-dialog.tsx create mode 100644 apps/cs/src/pages/groups/ui/role-select.tsx 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/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/pages/role/api/role-service.ts b/apps/cs/src/entities/role/api/role-service.ts similarity index 95% rename from apps/cs/src/pages/role/api/role-service.ts rename to apps/cs/src/entities/role/api/role-service.ts index d2db7e5bc..95aac330f 100644 --- a/apps/cs/src/pages/role/api/role-service.ts +++ b/apps/cs/src/entities/role/api/role-service.ts @@ -9,7 +9,7 @@ import type { } from "../model/role-interface"; export class RoleApiService implements RoleService { - public async getRoles(params: RoleParams): Promise { + public async getRoles(params?: RoleParams): Promise { const response = await client.get(ENDPOINT.USER_SERVICE.ROLES, { params, }); diff --git a/apps/cs/src/entities/role/index.ts b/apps/cs/src/entities/role/index.ts new file mode 100644 index 000000000..28fe5f7fb --- /dev/null +++ b/apps/cs/src/entities/role/index.ts @@ -0,0 +1,8 @@ +export { RoleApi } from "./api/role-service"; +export { + roleFormSchema, + type Role, + type RoleParams, + type RoleFormData, + type RoleList, +} from "./model/role-interface"; diff --git a/apps/cs/src/pages/role/model/role-interface.ts b/apps/cs/src/entities/role/model/role-interface.ts similarity index 94% rename from apps/cs/src/pages/role/model/role-interface.ts rename to apps/cs/src/entities/role/model/role-interface.ts index 1b9b8eea2..065d89259 100644 --- a/apps/cs/src/pages/role/model/role-interface.ts +++ b/apps/cs/src/entities/role/model/role-interface.ts @@ -28,7 +28,7 @@ export type RoleList = PageableList; export type RoleFormData = z.infer; export interface RoleService { - getRoles: (params: RoleParams) => Promise; + getRoles: (params?: RoleParams) => Promise; getRoleInfo: (roleId: string) => Promise; createRole: (data: RoleFormData) => Promise; updateRole: (roleId: string, data: RoleFormData) => Promise; diff --git a/apps/cs/src/entities/site/ui/site-select.tsx b/apps/cs/src/entities/site/ui/site-select.tsx index 6c5a0d005..3d56a5658 100644 --- a/apps/cs/src/entities/site/ui/site-select.tsx +++ b/apps/cs/src/entities/site/ui/site-select.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { + cn, Select, SelectContent, SelectItem, @@ -12,12 +13,14 @@ import { SiteApi } from "../api/site-service"; type SiteSelectProps = { placeholder?: string; enabledAllSite?: boolean; + className?: string; } & React.ComponentPropsWithoutRef; function SiteSelect({ defaultValue, placeholder = "선택", enabledAllSite, + className, ...props }: SiteSelectProps) { const { data: sites } = useQuery({ @@ -36,7 +39,7 @@ function SiteSelect({ return ( + + + + )} + /> + + + + 그룹명 + + ( + + + + + + + )} + /> + + + + 그룹구분 + + ( + + + + + + + )} + /> + + + + 그룹설명 + + ( + + +