diff --git a/apps/cs/app/site/register/[id]/page.tsx b/apps/cs/app/site/register/[id]/page.tsx new file mode 100644 index 000000000..685d77172 --- /dev/null +++ b/apps/cs/app/site/register/[id]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage as VModifyPage } from "@/pages/site"; + +export default async function ModifyPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} diff --git a/apps/cs/app/site/register/page.tsx b/apps/cs/app/site/register/page.tsx index a11d0b60c..01e25cc43 100644 --- a/apps/cs/app/site/register/page.tsx +++ b/apps/cs/app/site/register/page.tsx @@ -1,2 +1,2 @@ -import { RegisterPage } from "@/pages/site"; -export default RegisterPage; +import { CreatePage } from "@/pages/site"; +export default CreatePage; diff --git a/apps/cs/package.json b/apps/cs/package.json index 1797b4828..05c224ea3 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -11,14 +11,20 @@ "dependencies": { "@common/assets": "workspace:*", "@common/components": "workspace:*", + "@hookform/resolvers": "^4.1.3", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", "@tanstack/react-query": "^5.67.1", "@tanstack/react-table": "^8.21.2", "axios": "^1.8.1", "chart.js": "^4.4.8", + "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "next": "15.1.7", "react-chartjs-2": "^5.3.0", - "tailwindcss-animate": "^1.0.7" + "react-hook-form": "^7.54.2", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/apps/cs/src/app/index.tsx b/apps/cs/src/app/index.tsx index 672023d15..eb1d7f7c7 100644 --- a/apps/cs/src/app/index.tsx +++ b/apps/cs/src/app/index.tsx @@ -1,16 +1,12 @@ import "./styles/base.css"; import "./styles/components.css"; -import { AuthProvider, CookieProvider, QueryProvider } from "@/shared/provider"; +import { AppProvider } from "@/shared/provider"; const App = ({ children }: Readonly<{ children: React.ReactNode }>) => { return ( - - - {children} - - + {children} ); diff --git a/apps/cs/src/app/styles/components.css b/apps/cs/src/app/styles/components.css index 4d5caaf96..ec137cf25 100644 --- a/apps/cs/src/app/styles/components.css +++ b/apps/cs/src/app/styles/components.css @@ -5,7 +5,7 @@ @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; } .card-border { - @apply shadow-blur border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)]; + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; } .card-header { @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; @@ -13,4 +13,13 @@ .card-footer { @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; } + + [data-mode="dark"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + } } diff --git a/apps/cs/src/entities/auth/login/api/login-service.ts b/apps/cs/src/entities/auth/login/api/login-service.ts index 722470184..8c14547a0 100644 --- a/apps/cs/src/entities/auth/login/api/login-service.ts +++ b/apps/cs/src/entities/auth/login/api/login-service.ts @@ -31,7 +31,7 @@ export class LoginApiService implements LoginService { const tokenId = data[AUTH_USER_ID]; if (!accessToken || !tokenId) { - throw new Error("[login] Missing required authentication tokens"); + throw new Error("Missing required authentication tokens"); } tokenManager.setToken(CLAIM_NAME, accessToken); @@ -42,11 +42,9 @@ export class LoginApiService implements LoginService { ); } catch (error) { if (error instanceof AxiosError) { - throw new Error( - `Login failed: ${error.response?.data?.message || error.message}`, - ); + throw new Error(`Login failed: ${error.response?.data?.message}`); } - throw error; + throw new Error(`Login failed: ${error}`); } } @@ -62,22 +60,19 @@ export class LoginApiService implements LoginService { const tokenId = data[AUTH_USER_ID]; if (!accessToken || !tokenId) { - throw new Error( - "[silentRefresh] Missing required authentication tokens", - ); + throw new Error("Missing required authentication tokens"); } tokenManager.setToken(CLAIM_NAME, accessToken); tokenManager.setToken(AUTH_USER_ID, tokenId); } catch (error) { - console.warn("Token has expired, logging out."); tokenManager.clearAuthToken(); if (error instanceof AxiosError) { throw new Error( - `Silent refresh failed: ${error.response?.data?.message || error.message}`, + `Silent refresh failed: ${error.response?.data?.message}`, ); } - throw error; + throw new Error(`Silent refresh failed: ${error}`); } } } diff --git a/apps/cs/src/entities/code/api/code-service.ts b/apps/cs/src/entities/code/api/code-service.ts new file mode 100644 index 000000000..3a789f2aa --- /dev/null +++ b/apps/cs/src/entities/code/api/code-service.ts @@ -0,0 +1,14 @@ +import { client } from "@/shared/api/client"; +import { ENDPOINT } from "@/shared/config/api"; +import type { CodeService, ICode } from "../model/code-interface"; + +export class CodeApiService implements CodeService { + public async getCode(upCd: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`, + ); + return response.data; + } +} + +export const CodeApi = new CodeApiService(); diff --git a/apps/cs/src/entities/code/index.ts b/apps/cs/src/entities/code/index.ts new file mode 100644 index 000000000..0b6633f4b --- /dev/null +++ b/apps/cs/src/entities/code/index.ts @@ -0,0 +1 @@ +export { CodeSelect } from "./ui/code-select"; diff --git a/apps/cs/src/entities/code/model/code-interface.ts b/apps/cs/src/entities/code/model/code-interface.ts new file mode 100644 index 000000000..0d066e6da --- /dev/null +++ b/apps/cs/src/entities/code/model/code-interface.ts @@ -0,0 +1,9 @@ +export interface ICode { + lwpoCd: string; + upCd: string; + lwpoCdNm: string; +} + +export interface CodeService { + getCode(upCd: string): Promise; +} diff --git a/apps/cs/src/entities/code/ui/code-select.tsx b/apps/cs/src/entities/code/ui/code-select.tsx new file mode 100644 index 000000000..5d73ee1d3 --- /dev/null +++ b/apps/cs/src/entities/code/ui/code-select.tsx @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { ENDPOINT } from "@/shared/config/api"; +import { CodeApi } from "../api/code-service"; + +type CodeSelectProps = { + upCd: string; + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; +}; + +function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) { + const { data: code } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, upCd], + queryFn: () => CodeApi.getCode(upCd), + enabled: !!upCd, + }); + + const defaultValueText = + code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? "선택"; + + return ( + + ); +} + +export { CodeSelect }; diff --git a/apps/cs/src/entities/site/api/site-service.ts b/apps/cs/src/entities/site/api/site-service.ts index 89ba82350..478143392 100644 --- a/apps/cs/src/entities/site/api/site-service.ts +++ b/apps/cs/src/entities/site/api/site-service.ts @@ -1,18 +1,45 @@ import { client } from "@/shared/api/client"; import { ENDPOINT } from "@/shared/config/api"; -import type { - ISiteQuery, - ISiteInfo, - SiteService, +import { + siteFormSchema, + type ISiteInfo, + type SiteService, + type ISiteDetail, + type ISiteParams, + type SiteFormData, } from "../model/site-interface"; export class SiteApiService implements SiteService { - public async siteInfo(query: ISiteQuery): Promise { + public async siteInfo(params: ISiteParams): Promise { const response = await client.get( `${ENDPOINT.CMS_SERVICE.SITES}`, - { params: query }, + { params }, ); + return response.data; + } + public async siteDetail(siteId: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`, + ); + return response.data; + } + + public async modifySite(siteId: string, data: SiteFormData): Promise { + const validatedData = siteFormSchema.parse(data); + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`, + validatedData, + ); + return response.data; + } + + public async createSite(data: SiteFormData): Promise { + const validatedData = siteFormSchema.parse(data); + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.SITES}`, + validatedData, + ); return response.data; } } diff --git a/apps/cs/src/entities/site/index.ts b/apps/cs/src/entities/site/index.ts index 92ccd0ff7..47f9d7b50 100644 --- a/apps/cs/src/entities/site/index.ts +++ b/apps/cs/src/entities/site/index.ts @@ -1 +1,2 @@ export { SiteApi } from "./api/site-service"; +export * from "./model/site-interface"; diff --git a/apps/cs/src/entities/site/model/site-interface.ts b/apps/cs/src/entities/site/model/site-interface.ts index 8ba2da298..4d03e2c8b 100644 --- a/apps/cs/src/entities/site/model/site-interface.ts +++ b/apps/cs/src/entities/site/model/site-interface.ts @@ -1,4 +1,6 @@ -export interface ISiteQuery { +import { z } from "zod"; + +export interface ISiteParams { siteNm: string; siteKndCd: string; useYn: string; @@ -12,7 +14,10 @@ export interface ISite { siteNm: string; siteKindCode: string; bscSiteYn: string; + siteAddr: string; useYn: string; + regDt: string; + rgTrNm: string; } export interface ISiteInfo { @@ -44,6 +49,39 @@ export interface ISiteInfo { empty: boolean; } -export interface SiteService { - siteInfo(query: ISiteQuery): Promise; +export interface ISiteDetail extends SiteFormData { + siteId: string; + siteKindNm: string; + basicSiteYn: string; + faxNumber: string; + regDt: string; + rgTr: string; + mdfcnDt: string; + mdFr: string; + rgTrNm: string; + mdFrNm: string; +} + +export const siteFormSchema = z.object({ + siteId: z.string().max(2).nonempty(), + siteNm: z.string().max(100).nonempty(), + siteExpln: z.string().max(1000).optional(), + siteKndCd: z.string().max(10).nonempty(), + siteAddr: z.string().max(2000).optional(), + faxNo: z.string().max(100).optional(), + telNo: z.string().max(1000).optional(), + lwndCn: z.string().max(2000).optional(), + bscSiteYn: z.string(), + bkmkIcon: z.string().max(100).optional(), + useYn: z.string(), + siteSkn: z.string().max(10).optional(), +}); + +export type SiteFormData = z.infer; + +export interface SiteService { + siteInfo(query: ISiteParams): Promise; + siteDetail(siteId: string): Promise; + modifySite(siteId: string, data: SiteFormData): Promise; + createSite(data: SiteFormData): Promise; } diff --git a/apps/cs/src/pages/auth/login/ui/login-page.tsx b/apps/cs/src/pages/auth/login/ui/login-page.tsx index c8eb1e2f9..39a8b7411 100644 --- a/apps/cs/src/pages/auth/login/ui/login-page.tsx +++ b/apps/cs/src/pages/auth/login/ui/login-page.tsx @@ -1,5 +1,3 @@ -"use client"; - import Image from "next/image"; import { images } from "@common/assets"; import { LoginApi } from "@/entities/auth/login"; diff --git a/apps/cs/src/pages/site/index.ts b/apps/cs/src/pages/site/index.ts index 22c7fef7b..40c65cf04 100644 --- a/apps/cs/src/pages/site/index.ts +++ b/apps/cs/src/pages/site/index.ts @@ -1,2 +1,3 @@ -export { RegisterPage } from "./ui/register-page"; +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; export { SitePage } from "./ui/site-page"; diff --git a/apps/cs/src/pages/site/model/__mocks__.tsx b/apps/cs/src/pages/site/model/table-columns.tsx similarity index 74% rename from apps/cs/src/pages/site/model/__mocks__.tsx rename to apps/cs/src/pages/site/model/table-columns.tsx index 2b55da7d8..ddf211aea 100644 --- a/apps/cs/src/pages/site/model/__mocks__.tsx +++ b/apps/cs/src/pages/site/model/table-columns.tsx @@ -1,3 +1,4 @@ +import dayjs from "dayjs"; import { createColumnHelper } from "@tanstack/react-table"; import type { ISite } from "@/entities/site/model/site-interface"; @@ -16,8 +17,8 @@ const columns = [ id: "siteNm", header: "사이트명", }), - columnHelper.accessor("siteKindCode", { - id: "unknown1", + columnHelper.accessor("siteAddr", { + id: "siteAddr", header: "대표 URL", }), columnHelper.accessor("bscSiteYn", { @@ -30,13 +31,17 @@ const columns = [ header: "사용 여부", cell: (info) => (info.getValue() ? "Y" : "N"), }), - columnHelper.accessor("siteId", { - id: "unknown2", + columnHelper.accessor("rgTrNm", { + id: "rgTrNm", header: "등록자", }), - columnHelper.accessor("siteId", { - id: "ununknown3", + columnHelper.accessor("regDt", { + id: "regDt", header: "등록일", + cell: (info) => { + const date = dayjs(info.getValue()); + return date.format("YYYY-MM-DD"); + }, }), ]; diff --git a/apps/cs/src/pages/site/ui/create-page.tsx b/apps/cs/src/pages/site/ui/create-page.tsx new file mode 100644 index 000000000..10d2d9030 --- /dev/null +++ b/apps/cs/src/pages/site/ui/create-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { useMutation } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { SiteApi, siteFormSchema, type SiteFormData } from "@/entities/site"; +import { SitePageLayout } from "./layout"; +import { SiteForm } from "./site-form"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, +} from "@common/components/ui"; + +function CreatePage() { + const router = useRouter(); + const { mutateAsync: createSite } = useMutation({ + mutationFn: (data: SiteFormData) => SiteApi.createSite(data), + onSuccess: () => { + setIsCreateSuccess(true); + }, + }); + + const [isCreateSuccess, setIsCreateSuccess] = useState(false); + + const form = useForm({ + resolver: zodResolver(siteFormSchema), + }); + + return ( + + + router.back()}> + +
+

등록되었습니다.

+
+ + + + + +
+
+
+ ); +} + +export { CreatePage }; diff --git a/apps/cs/src/pages/site/ui/domain-management-dialog.tsx b/apps/cs/src/pages/site/ui/domain-management-dialog.tsx new file mode 100644 index 000000000..c675f92d9 --- /dev/null +++ b/apps/cs/src/pages/site/ui/domain-management-dialog.tsx @@ -0,0 +1,210 @@ +import { useState } from "react"; +import { + Dialog, + DialogTrigger, + Button, + DialogContent, + DialogHeader, + DialogTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Input, + Checkbox, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + DialogFooter, + DialogClose, +} from "@common/components"; + +export type Domain = { + id: number; + domainUrl: string; + isRepresentative: boolean; + isActive: "true" | "false"; +}; + +type DomainManagementDialogProps = { + triggerDisabled?: boolean; + handleSave: (rows: Domain[]) => void; +}; + +function DomainManagementDialog({ + triggerDisabled, + handleSave, +}: DomainManagementDialogProps) { + const [rows, setRows] = useState([]); + const [rowSelection, setRowSelection] = useState([]); + + const onSave = () => { + handleSave(rows); + }; + + const handleAddDomain = () => { + const newDomain: Domain = { + id: Date.now(), + domainUrl: "", + isRepresentative: rows.length === 0, + isActive: "true", + }; + setRows([...rows, newDomain]); + }; + + const handleDeleteDomain = () => { + const filteredRows = rows.filter((row) => !rowSelection.includes(row.id)); + const hasRepresentative = filteredRows.some((row) => row.isRepresentative); + + let updatedRows = filteredRows; + if (!hasRepresentative && filteredRows.length > 0) { + updatedRows = filteredRows.map((row, index) => + index === 0 ? { ...row, isRepresentative: true } : row, + ); + } + + setRows(updatedRows); + setRowSelection([]); + }; + + const handleDomainUrlChange = (id: number, value: string) => { + setRows( + rows.map((row) => (row.id === id ? { ...row, domainUrl: value } : row)), + ); + }; + + const handleRepresentativeChange = (id: number) => { + setRows( + rows.map((row) => ({ + ...row, + isRepresentative: row.id === id, + })), + ); + }; + + const handleActiveChange = (id: number, value: "true" | "false") => { + setRows( + rows.map((row) => (row.id === id ? { ...row, isActive: value } : row)), + ); + }; + + const toggleRowSelection = (id: number) => { + setRowSelection((prev) => { + if (prev.includes(id)) { + return prev.filter((item) => item !== id); + } else { + return [...prev, id]; + } + }); + }; + + return ( + + + + + + + 도메인 관리 + +
+ + +
+
+ + + + + + + + + + + NO + 선택 + 도메인URL + 대표URL + 사용여부 + + + + {rows.map((row, idx) => ( + + {idx + 1} + + toggleRowSelection(row.id)} + className="cursor-pointer" + /> + + + + handleDomainUrlChange(row.id, e.target.value) + } + className="!text-[1.3rem]" + /> + + + handleRepresentativeChange(row.id)} + className="cursor-pointer" + /> + + + + + + ))} + +
+
+ + + + + + + + +
+
+ ); +} + +export { DomainManagementDialog }; diff --git a/apps/cs/src/pages/site/ui/ip-management-dialog.tsx b/apps/cs/src/pages/site/ui/ip-management-dialog.tsx index 04b71afc7..06d9638b3 100644 --- a/apps/cs/src/pages/site/ui/ip-management-dialog.tsx +++ b/apps/cs/src/pages/site/ui/ip-management-dialog.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useState } from "react"; import { Dialog, @@ -18,6 +16,8 @@ import { RadioGroup, RadioGroupItem, Checkbox, + DialogFooter, + DialogClose, } from "@common/components"; type IP = { @@ -26,11 +26,15 @@ type IP = { allowed: boolean; }; -function IPManagementDialog() { +type IPManagementDialogProps = { + triggerDisabled?: boolean; +}; + +function IPManagementDialog({ triggerDisabled }: IPManagementDialogProps) { const [rows, setRows] = useState([]); const [rowSelection, setRowSelection] = useState([]); - const handleSave = () => {}; + const onSave = () => {}; const handleAddIP = () => { const newIP: IP = { @@ -72,9 +76,9 @@ function IPManagementDialog() { return ( - + - + IP 관리 @@ -82,9 +86,6 @@ function IPManagementDialog() { - @@ -98,7 +99,7 @@ function IPManagementDialog() {
- +
@@ -156,6 +157,18 @@ function IPManagementDialog() {
+ + + + + + + +
); diff --git a/apps/cs/src/pages/site/ui/layout.tsx b/apps/cs/src/pages/site/ui/layout.tsx index a5d85dee3..3fec47e17 100644 --- a/apps/cs/src/pages/site/ui/layout.tsx +++ b/apps/cs/src/pages/site/ui/layout.tsx @@ -1,15 +1,11 @@ -import { BaseLayout } from "@common/components"; - function SitePageLayout({ children }: { children: React.ReactNode }) { return ( - -
-

- 사이트 관리 -

- {children} -
-
+
+

+ 사이트 관리 +

+ {children} +
); } diff --git a/apps/cs/src/pages/site/ui/modify-page.tsx b/apps/cs/src/pages/site/ui/modify-page.tsx new file mode 100644 index 000000000..5534c75bc --- /dev/null +++ b/apps/cs/src/pages/site/ui/modify-page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + SiteApi, + siteFormSchema, + type ISiteDetail, + type SiteFormData, +} from "@/entities/site"; +import { ENDPOINT } from "@/shared/config/api"; +import { SitePageLayout } from "./layout"; +import { SiteForm } from "./site-form"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, +} from "@common/components/ui"; + +function ModifyPage({ siteId }: { siteId: string }) { + const isModifyMode = !!siteId; + + const router = useRouter(); + const { data: siteDetail } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES, siteId], + queryFn: () => SiteApi.siteDetail(siteId), + select: (data) => ({ + ...data, + faxNo: data.faxNumber, + bscSiteYn: data.basicSiteYn, + }), + enabled: isModifyMode, + }); + + const { mutateAsync: modifySite } = useMutation({ + mutationFn: (data: SiteFormData) => SiteApi.modifySite(siteId, data), + onSuccess: () => { + setIsModifySuccess(true); + }, + }); + + const [isModifySuccess, setIsModifySuccess] = useState(false); + + const form = useForm({ + resolver: zodResolver(siteFormSchema), + }); + + useEffect(() => { + if (!siteDetail) return; + + const keys = Object.keys(siteFormSchema.shape); + for (const key of keys) { + form.setValue( + key as keyof SiteFormData, + siteDetail[key as keyof ISiteDetail] ?? "", + ); + } + }, [siteDetail]); + + return ( + + + router.back()}> + +
+

수정되었습니다.

+
+ + + + + +
+
+
+ ); +} + +export { ModifyPage }; diff --git a/apps/cs/src/pages/site/ui/register-page.tsx b/apps/cs/src/pages/site/ui/register-page.tsx deleted file mode 100644 index 3c35ae8e0..000000000 --- a/apps/cs/src/pages/site/ui/register-page.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client"; - -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Input, - Label, - RadioGroup, - RadioGroupItem, - Table, - TableBody, - TableCell, - TableHead, - TableRow, -} from "@common/components"; -import { SitePageLayout } from "./layout"; -import { IPManagementDialog } from "./ip-management-dialog"; - -function RegisterPage() { - return ( - -
-
-

사이트 등록/수정

-
-
- - - - - - - - 사이트 코드 - - - - - - 사이트 구분 - -
- - - - - - - 내부 - 외부 - - - - -
-
-
- - 사이트 디자인 - - - - - - - - 라이트 - 다크 - - - - - - - 사이트명 - - - - - - 사이트 설명 - - - - - - 사이트 하단 표기 주소 - - - - - - 사이트 하단 표기 전화번호 - - - - - - 사이트 하단 표기 팩스번호 - - - - - - 사이트 하단 표기 내용 - - - - - - 북마크 아이콘 - - - - - - 기본 사이트 여부 - - -
- - -
-
- - -
-
-
-
- - 사용 여부 - - -
- - -
-
- - -
-
-
-
-
-
-
-
-
- - - -
-
- ); -} - -export { RegisterPage }; diff --git a/apps/cs/src/pages/site/ui/site-form.tsx b/apps/cs/src/pages/site/ui/site-form.tsx new file mode 100644 index 000000000..c1a5dfc02 --- /dev/null +++ b/apps/cs/src/pages/site/ui/site-form.tsx @@ -0,0 +1,390 @@ +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { type UseFormReturn } from "react-hook-form"; +import { + Button, + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { CodeSelect } from "@/entities/code"; +import { type ISiteDetail, type SiteFormData } from "@/entities/site"; +import { + FormItem, + FormControl, + FormField, + Form, + FormMessage, +} from "@/shared/ui/form"; +import { IPManagementDialog } from "./ip-management-dialog"; +import { + DomainManagementDialog, + type Domain, +} from "./domain-management-dialog"; + +type SiteFormProps = { + form: UseFormReturn; + siteId?: string; + handleSave: (data: SiteFormData) => void; +}; + +function SiteForm({ form, siteId, handleSave }: SiteFormProps) { + const router = useRouter(); + + const [viewState, setViewState] = useState>({}); + const [representativeDomain, setRepresentativeDomain] = useState(""); + + const isModifyMode = !!siteId; + + const handleViewStateChange = (key: keyof ISiteDetail) => (value: string) => { + setViewState({ ...viewState, [key]: value }); + }; + + const handleDomainSave = (rows: Domain[]) => { + setRepresentativeDomain( + rows.find((row) => row.isRepresentative)?.domainUrl || "", + ); + }; + + const onSubmit = (data: SiteFormData) => { + handleSave(data); + }; + + return ( +
+ +
+
+

+ 사이트 등록/수정 +

+
+
+ + + + + + + + 사이트 코드 + + ( + + + + + + + )} + /> + + + + 사이트 구분 + +
+ ( + + + { + handleViewStateChange("siteKndCd")(value); + field.onChange(value); + }} + /> + + + + )} + /> + {isModifyMode && ( + + )} +
+
+
+ + 사이트 디자인 + + ( + + + + + + + )} + /> + + + + 사이트명 + + ( + + + + + + + )} + /> + + + + 사이트 설명 + + ( + + + + + + + )} + /> + + + {isModifyMode && ( + + 사이트 도메인 + +
+
+ {representativeDomain} +
+ +
+
+
+ )} + + 사이트 하단 표기 주소 + + ( + + + + + + + )} + /> + + + + 사이트 하단 표기 전화번호 + + ( + + + + + + + )} + /> + + + + 사이트 하단 표기 팩스번호 + + ( + + + + + + + )} + /> + + + + 사이트 하단 표기 내용 + + ( + + + + + + + )} + /> + + + + 북마크 아이콘 + + ( + + + + + + + )} + /> + + + + 기본 사이트 여부 + + ( + + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> +
+
+ + 사용 여부 + + ( + + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> +
+
+
+
+
+
+
+ + + {isModifyMode && ( + + )} +
+
+ + ); +} + +export { SiteForm }; diff --git a/apps/cs/src/pages/site/ui/site-page.tsx b/apps/cs/src/pages/site/ui/site-page.tsx index c13284ce8..4b095caff 100644 --- a/apps/cs/src/pages/site/ui/site-page.tsx +++ b/apps/cs/src/pages/site/ui/site-page.tsx @@ -7,14 +7,20 @@ import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { Button, Input, DataTable } from "@common/components"; -import { SiteApi } from "@/entities/site/api/site-service"; +import { SiteApi, type ISite, type ISiteParams } from "@/entities/site"; import { ENDPOINT } from "@/shared/config/api"; -import { columns } from "../model/__mocks__"; +import { columns } from "../model/table-columns"; import { SitePageLayout } from "./layout"; -import type { ISiteQuery } from "@/entities/site/model/site-interface"; function SitePage() { - const [siteQuery, setSiteQuery] = useState({ + const router = useRouter(); + const { data: siteInfo, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES], + queryFn: () => SiteApi.siteInfo(siteQuery), + enabled: false, + }); + + const [siteQuery, setSiteQuery] = useState({ siteNm: "", siteKndCd: "", useYn: "", @@ -23,10 +29,10 @@ function SitePage() { sort: [], }); - const { data: siteInfo, refetch } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.SITES], - queryFn: () => SiteApi.siteInfo(siteQuery), - enabled: false, + const table = useReactTable({ + data: siteInfo?.content ?? [], + columns, + getCoreRowModel: getCoreRowModel(), }); const handleSubmit = (e: React.FormEvent) => { @@ -34,15 +40,13 @@ function SitePage() { refetch(); }; - const router = useRouter(); - const table = useReactTable({ - data: siteInfo?.content ?? [], - columns, - getCoreRowModel: getCoreRowModel(), - }); + const handleRowClick = (row: ISite) => { + router.push(`/site/register/${row.siteId}`); + }; useEffect(() => { refetch(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( @@ -71,7 +75,7 @@ function SitePage() {
- +
diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 052eea56a..bab8cac7b 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -10,6 +10,7 @@ export const ENDPOINT = { }, CMS_SERVICE: { SITES: "/cms-service/api/v1/sites", + CODE: "/cms-service/api/v1/code/cache", }, }; diff --git a/apps/cs/src/shared/lib/cookie.ts b/apps/cs/src/shared/lib/cookie.ts index 82db637bd..8f5d0011b 100644 --- a/apps/cs/src/shared/lib/cookie.ts +++ b/apps/cs/src/shared/lib/cookie.ts @@ -1,5 +1,3 @@ -"use client"; - import Cookies from "js-cookie"; export function getCookie(name: string): string | undefined { diff --git a/apps/cs/src/shared/provider/app-provider.tsx b/apps/cs/src/shared/provider/app-provider.tsx new file mode 100644 index 000000000..6c1d45410 --- /dev/null +++ b/apps/cs/src/shared/provider/app-provider.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { BaseLayout } from "@common/components/ui"; +import { AuthProvider } from "./auth-provider"; +import { CookieProvider } from "./cookie-provider"; +import { QueryProvider } from "./query-provider"; + +const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { + return ( + + + + {children} + + + + ); +}; + +export { AppProvider }; diff --git a/apps/cs/src/shared/provider/auth-provider.tsx b/apps/cs/src/shared/provider/auth-provider.tsx index 7c827e948..60b273641 100644 --- a/apps/cs/src/shared/provider/auth-provider.tsx +++ b/apps/cs/src/shared/provider/auth-provider.tsx @@ -1,12 +1,12 @@ "use client"; +import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { createContext, useContext, useState } from "react"; import { client } from "../api/client"; import { ENDPOINT } from "../config/api"; import { AUTH_USER_ID } from "../config/env"; import { getCookie } from "../lib/cookie"; -import { AxiosError } from "axios"; import { LoginApi } from "@/entities/auth/login"; interface IUserInfo { @@ -50,28 +50,32 @@ export interface AuthContextType { export const AuthContext = createContext(null); export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); const [userInfo, setUserInfo] = useState(null); useQuery({ queryKey: [ENDPOINT.USER_SERVICE.USERS], queryFn: async () => { try { + const userId = getCookie(AUTH_USER_ID); + + if (!userId) { + throw new Error("Missing required authentication tokens"); + } + const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/${getCookie(AUTH_USER_ID)}`, + `${ENDPOINT.USER_SERVICE.USERS}/${userId}`, ); setUserInfo(response.data); return response; } catch (error) { console.error(error); - if (error instanceof AxiosError) { - if (error.response?.status === 401) { - try { - await LoginApi.silentRefresh(); - } catch (refreshError) { - console.error("Token refresh failed:", refreshError); - setUserInfo(null); - } - } + try { + await LoginApi.silentRefresh(); + } catch (refreshError) { + console.error("Token refresh failed:", refreshError); + setUserInfo(null); + router.push("/auth/login"); } } }, diff --git a/apps/cs/src/shared/provider/index.ts b/apps/cs/src/shared/provider/index.ts index 8d018ff08..6b1832501 100644 --- a/apps/cs/src/shared/provider/index.ts +++ b/apps/cs/src/shared/provider/index.ts @@ -1,3 +1,3 @@ -export { AuthProvider, useAuth } from "./auth-provider"; -export { CookieProvider, useCookie } from "./cookie-provider"; -export { QueryProvider } from "./query-provider"; +export { AppProvider } from "./app-provider"; +export { useAuth } from "./auth-provider"; +export { useCookie } from "./cookie-provider"; diff --git a/apps/cs/src/shared/provider/query-provider.tsx b/apps/cs/src/shared/provider/query-provider.tsx index 65a8ca837..f16c42072 100644 --- a/apps/cs/src/shared/provider/query-provider.tsx +++ b/apps/cs/src/shared/provider/query-provider.tsx @@ -1,5 +1,3 @@ -"use client"; - import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const queryClient = new QueryClient(); diff --git a/apps/cs/src/shared/ui/form.tsx b/apps/cs/src/shared/ui/form.tsx new file mode 100644 index 000000000..bd579d263 --- /dev/null +++ b/apps/cs/src/shared/ui/form.tsx @@ -0,0 +1,175 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { cn, Label } from "@common/components"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +