feat: 공지게시판 목록 작업

This commit is contained in:
2025-04-29 16:08:23 +09:00
parent 9d3b110077
commit 6ba562a1b4
143 changed files with 294 additions and 9731 deletions
@@ -1,2 +0,0 @@
import { BannerPage } from "@/pages/banner";
export default BannerPage;
@@ -1,10 +0,0 @@
import { ModifyPage as VModifyPage } from "@/pages/banner";
export default async function ModifyPage({
params,
}: {
params: Promise<{ sequential: string }>;
}) {
const { sequential } = await params;
return <VModifyPage sequential={sequential} />;
}
@@ -1,2 +0,0 @@
import { CreatePage } from "@/pages/banner";
export default CreatePage;
@@ -1,2 +0,0 @@
import { BbsPage } from "@/pages/bbs";
export default BbsPage;
@@ -1,10 +0,0 @@
import { ModifyPage as VModifyPage } from "@/pages/bbs";
export default async function ModifyPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <VModifyPage bbsId={id} />;
}
@@ -1,2 +0,0 @@
import { CreatePage } from "@/pages/bbs";
export default CreatePage;
@@ -1,2 +0,0 @@
import { ContentsPage } from "@/pages/contents";
export default ContentsPage;
@@ -1,10 +0,0 @@
import { ModifyPage as VModifyPage } from "@/pages/contents";
export default async function ModifyPage({
params,
}: {
params: Promise<{ sequential: string }>;
}) {
const { sequential } = await params;
return <VModifyPage sequential={sequential} />;
}
@@ -1,2 +0,0 @@
import { CreatePage } from "@/pages/contents";
export default CreatePage;
@@ -1,2 +0,0 @@
import { DashboardPage } from "@/pages/dashboard";
export default DashboardPage;
@@ -1,2 +0,0 @@
import { FilePage } from "@/pages/file";
export default FilePage;
@@ -1,2 +0,0 @@
import { ImagePage } from "@/pages/image";
export default ImagePage;
@@ -1,10 +0,0 @@
import { ModifyPage as VModifyPage } from "@/pages/image";
export default async function ModifyPage({
params,
}: {
params: Promise<{ sequential: string }>;
}) {
const { sequential } = await params;
return <VModifyPage sequential={sequential} />;
}
@@ -1,2 +0,0 @@
import { CreatePage } from "@/pages/image";
export default CreatePage;
@@ -5,7 +5,6 @@ import { useQueryClient } from "@tanstack/react-query";
import type { MenuItem } from "@common/components/api";
import { BaseLayout } from "@common/components/ui";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
export default function WithBaseLayout({
children,
@@ -29,7 +28,7 @@ export default function WithBaseLayout({
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
{children}
</BaseLayout>
);
}
@@ -1,2 +0,0 @@
import { MenuPage } from "@/pages/menu";
export default MenuPage;
@@ -1,2 +0,0 @@
import { ModulePage } from "@/pages/module";
export default ModulePage;
@@ -1,10 +0,0 @@
import { ModifyPage as VModifyPage } from "@/pages/module";
export default async function ModifyPage({
params,
}: {
params: Promise<{ sequential: string }>;
}) {
const { sequential } = await params;
return <VModifyPage sequential={sequential} />;
}
@@ -1,2 +0,0 @@
import { CreatePage } from "@/pages/module";
export default CreatePage;
@@ -1,2 +0,0 @@
import { OrganizationPage } from "@/pages/organization";
export default OrganizationPage;
@@ -0,0 +1,10 @@
import { NoticePage } from "@/pages/notice";
export default async function PstBbsNoticePage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <NoticePage bbsId={bbsId} />;
}
@@ -1,2 +0,0 @@
import { SitePage } from "@/pages/site";
export default SitePage;
@@ -1,10 +0,0 @@
import { ModifyPage as VModifyPage } from "@/pages/site";
export default async function ModifyPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <VModifyPage siteId={id} />;
}
@@ -1,2 +0,0 @@
import { CreatePage } from "@/pages/site";
export default CreatePage;
@@ -1,2 +0,0 @@
import { TemplatePage } from "@/pages/template";
export default TemplatePage;
@@ -1,10 +0,0 @@
import { ModifyPage as VModifyPage } from "@/pages/template";
export default async function ModifyPage({
params,
}: {
params: Promise<{ sequential: string }>;
}) {
const { sequential } = await params;
return <VModifyPage sequential={sequential} />;
}
@@ -1,2 +0,0 @@
import { CreatePage } from "@/pages/template";
export default CreatePage;
@@ -1,2 +0,0 @@
import { TermsPage } from "@/pages/terms";
export default TermsPage;
@@ -1,10 +0,0 @@
import { ModifyPage as VModifyPage } from "@/pages/terms";
export default async function ModifyPage({
params,
}: {
params: Promise<{ sequential: string }>;
}) {
const { sequential } = await params;
return <VModifyPage sequential={sequential} />;
}
@@ -1,2 +0,0 @@
import { CreatePage } from "@/pages/terms";
export default CreatePage;
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { CLAIM_NAME, AUTH_USER_ID } from "./src/shared/config";
const serverApiUrl =
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:13000";
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
+1 -28
View File
@@ -1,32 +1,5 @@
import type { NextConfig } from "next";
const subDomain =
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
const nextConfig: NextConfig = {
async rewrites() {
return {
beforeFiles: [
{
source: "/_next/static/:path*",
destination: `${subDomain}/_next/static/:path*`,
has: [
{
type: "header",
key: "x-rewrite-me",
},
],
},
],
afterFiles: [
{
source: "/board/:path*",
destination: `${subDomain}/board/:path*`,
},
],
fallback: [],
};
},
};
const nextConfig: NextConfig = {};
export default nextConfig;
+2 -2
View File
@@ -3,9 +3,9 @@
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 13000",
"build": "next build",
"start": "next start",
"start": "next start -p 13000",
"lint": "next lint"
},
"dependencies": {
@@ -0,0 +1,18 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
PostList,
PostParams,
PostService,
} from "../model/post-interface";
export class PostApiService implements PostService {
public async getPostList(params: PostParams): Promise<PostList> {
const response = await client.get<PostList>(ENDPOINT.BOARD_SERVICE.POSTS, {
params,
});
return response.data;
}
}
export const PostApi = new PostApiService();
@@ -0,0 +1,7 @@
export { PostApi } from "./api/post-service";
export type {
Post,
PostList,
PostParams,
PostService,
} from "./model/post-interface";
@@ -0,0 +1,38 @@
import {
type PageableList,
type PageableParams,
} from "@/shared/model/api-interface";
export interface PostParams extends PageableParams {
keywordType?: string;
keyword?: string;
bbsId?: number;
delYn?: string;
frDateYmd?: string;
toDateYmd?: string;
}
export interface Post {
bbsId: number;
pstNo: number;
pstTtl: string;
inqNocs: number;
rfrncUrl: string;
popupYn: string;
upndFixYn: string;
prdPstgYn: string;
rootPath: string;
pstPath: string;
ansPstn: number;
wrtrNm: string;
wrtrCplc: string;
atflCd: string;
regDt: string;
rgtrNm: string;
}
export type PostList = PageableList<Post>;
export interface PostService {
getPostList(params: PostParams): Promise<PostList>;
}
@@ -1,51 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
BannerFormData,
BannerInfo,
BannerList,
BannerParams,
BannerService,
} from "../model/banner-interface";
export class BannerApiService implements BannerService {
public async getBannerList(params: BannerParams): Promise<BannerList> {
const response = await client.get<BannerList>(
`${ENDPOINT.CMS_SERVICE.BANNERS}`,
{ params },
);
return response.data;
}
public async getBannerInfo(bnrSn: string): Promise<BannerInfo> {
const response = await client.get<BannerInfo>(
`${ENDPOINT.CMS_SERVICE.BANNERS}/${bnrSn}`,
);
return response.data;
}
public async createBanner(data: BannerFormData): Promise<void> {
const response = await client.post<void>(
ENDPOINT.CMS_SERVICE.BANNERS,
data,
);
return response.data;
}
public async updateBanner(
bnrSn: string,
data: BannerFormData,
): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.BANNERS}/${bnrSn}`,
data,
);
return response.data;
}
public async deleteBanner(bnrSn: string): Promise<void> {
await client.delete(`${ENDPOINT.CMS_SERVICE.BANNERS}/${bnrSn}`);
}
}
export const BannerApi = new BannerApiService();
@@ -1,3 +0,0 @@
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
export { BannerPage } from "./ui/banner-page";
@@ -1,61 +0,0 @@
import { Upload, type HttpResponse } from "tus-js-client";
import { getCookie, setCookie } from "@/shared/lib";
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
type UploadOptions = {
handleUploadStart?: (upload: Upload) => void;
handleProgress?: (percentage: string) => void;
handleSuccess?: (upload: Upload) => void;
handleResponse?: (res: HttpResponse) => void;
};
export const handleUpload = (files: FileList, options?: UploadOptions) => {
let metadata = {};
const file = files.item(0);
if (!file) return;
metadata = {
filename: file.name,
filetype: file.type,
};
const atflCd = getCookie("atfl-cd-key");
if (atflCd) {
metadata = {
...metadata,
atflCd,
};
}
const upload = new Upload(file, {
endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
chunkSize: 10 * 1024 * 1024,
headers: {
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
},
metadata,
onError(error) {
console.error(`Failed because: ${error}`);
},
onProgress(bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
options?.handleProgress?.(percentage);
},
onAfterResponse(req, res) {
const fileInfo = res.getHeader("X-File-Info");
if (fileInfo === undefined || fileInfo === null) return;
setCookie("atfl-cd-key", JSON.parse(fileInfo)?.atflCd ?? "");
options?.handleResponse?.(res);
},
onSuccess() {
options?.handleSuccess?.(upload);
},
});
upload.start();
options?.handleUploadStart?.(upload);
console.log("upload start >>>>>", upload);
};
@@ -1,57 +0,0 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export const BANNER_TYPE = {
BNRMAIN: "BNRMAIN",
BNRFTER: "BNRFTER",
} as const;
export type BannerType = (typeof BANNER_TYPE)[keyof typeof BANNER_TYPE];
export interface BannerParams extends PageableParams {
bnrTypeCd?: BannerType;
bnrTtl?: string;
useYn?: string;
}
export interface Banner {
bnrSn: number;
bnrTypeCd: BannerType;
bnrTypeNm: string;
bnrTtl: string;
lnkgUrlAddr: string;
useYn: string;
atflCd: string;
mdfcnDt: string;
mdfr: string;
mdfrNm: string;
}
export interface BannerInfo extends Omit<Banner, "mdfr"> {
bnrExpln: string;
newWndwYn: string;
atflCd: string;
regDt: string;
rgTrNm: string;
}
export const bannerFormSchema = z.object({
bnrTypeCd: z.string(),
bnrTtl: z.string(),
bnrExpln: z.string().nullish(),
lnkgUrlAddr: z.string().nullish(),
newWndwYn: z.string(),
atflCd: z.string().nullish(),
useYn: z.string(),
});
export type BannerFormData = z.infer<typeof bannerFormSchema>;
export type BannerList = PageableList<Banner>;
export interface BannerService {
getBannerList: (params: BannerParams) => Promise<BannerList>;
getBannerInfo: (bnrSn: string) => Promise<BannerInfo>;
createBanner: (data: BannerFormData) => Promise<void>;
updateBanner: (bnrSn: string, data: BannerFormData) => Promise<void>;
deleteBanner: (bnrSn: string) => Promise<void>;
}
@@ -1,100 +0,0 @@
"use client";
import dayjs from "dayjs";
import Image from "next/image";
import { useQuery } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox, Skeleton } from "@common/components/ui";
import { AttachmentApi } from "@/entities/attachment";
import { ENDPOINT } from "@/shared/config";
import type { Banner } from "./banner-interface";
export const createColumns = (
imageRefs: React.MutableRefObject<Map<number, string>>,
): ColumnDef<Banner>[] => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
header: "배너 이미지",
cell: ({ row }) => (
<BannerImage
atflCd={row.original.atflCd}
bnrSn={row.original.bnrSn}
imageRefs={imageRefs}
/>
),
},
{
accessorKey: "bnrTtl",
header: "배너명",
},
{
accessorKey: "bnrTypeNm",
header: "배너구분",
},
{
accessorKey: "useYn",
header: "사용여부",
},
{
accessorKey: "mdfrNm",
header: "최종수정자",
},
{
accessorKey: "mdfcnDt",
header: "최종수정일",
cell: ({ row }) => {
const date = row.getValue("mdfcnDt") as string;
return date ? dayjs(date).format("YYYY-MM-DD") : "";
},
},
];
type BannerImageProps = {
atflCd: string;
bnrSn: number;
imageRefs: React.MutableRefObject<Map<number, string>>;
};
const BannerImage = ({ atflCd, bnrSn, imageRefs }: BannerImageProps) => {
const prevImage = imageRefs.current.get(bnrSn);
const { data: imageUrl, isLoading } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd],
queryFn: () => AttachmentApi.getImageUrlByAtflCd(atflCd),
select: (url) => {
if (url) imageRefs.current.set(bnrSn, url);
return url;
},
enabled: !prevImage && !!atflCd,
});
if (isLoading) return <Skeleton className="h-[4rem] w-[16rem]" />;
if (!imageUrl) return null;
return (
<div className="relative flex h-[4rem] w-[16rem] items-center justify-center">
<Image src={imageUrl} className="object-contain" fill alt="배너 이미지" />
</div>
);
};
@@ -1,44 +0,0 @@
"use client";
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import type { Post } from "@/entities/posting";
export const columns: ColumnDef<Post>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
accessorKey: "siteNm",
header: "사이트",
},
{
accessorKey: "pstgPrdSeNm",
header: "게시기간",
cell: ({ row }) => {
const pstgPrdSeNm = row.original.pstgPrdSeNm;
const pstgBgngDt = row.original.pstgBgngDt;
const pstgEndDt = row.original.pstgEndDt;
return `${pstgPrdSeNm} (${dayjs(pstgBgngDt).format("YYYY-MM-DD")} ~ ${dayjs(pstgEndDt).format("YYYY-MM-DD")})`;
},
},
];
@@ -1,296 +0,0 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { UseFormReturn } from "react-hook-form";
import type { HttpResponse, Upload } from "tus-js-client";
import {
FileUploader,
Input,
Label,
Progress,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Textarea,
} from "@common/components";
import { FormWrapper } from "@/widgets/form-wrapper";
import { CodeSelect } from "@/entities/code";
import { AttachmentApi } from "@/entities/attachment";
import { ENDPOINT } from "@/shared/config";
import { setCookie } from "@/shared/lib/cookie";
import { FormControl, FormField, FormItem } from "@/shared/ui/form";
import type { BannerFormData } from "../model/banner-interface";
import { handleUpload } from "../lib/utils";
type BannerFormProps = {
form: UseFormReturn<BannerFormData>;
bnrSn?: number;
handleSave: (data: BannerFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
};
function BannerForm({ form, bnrSn, ...props }: BannerFormProps) {
const atflCd = form.watch("atflCd");
const [imageUrl, setImageUrl] = useState("");
const [upload, setUpload] = useState<Upload | null>(null);
const [progress, setProgress] = useState(0);
const { data: attachmentData } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd],
queryFn: () => AttachmentApi.getAttachmentsInfo(atflCd!),
select: (data) => data?.[data.length - 1],
enabled: !!atflCd,
});
const handleProgress = (percentage: string) => {
setProgress(Number(percentage));
};
const handleUploadResponse = async (res: HttpResponse) => {
const fileInfo = res.getHeader("X-File-Info");
if (fileInfo === undefined || fileInfo === null) return;
const { atflCd, atflId } = JSON.parse(fileInfo) ?? {};
if (atflCd) form.setValue("atflCd", atflCd);
if (atflId) setImageUrl(await AttachmentApi.getImageUrl(atflId));
if (attachmentData)
await AttachmentApi.deleteAttachmentByAtflCd(attachmentData.atflCd);
};
useEffect(() => {
if (!attachmentData) return;
(async () => {
setImageUrl(await AttachmentApi.getImageUrl(attachmentData.atflId));
})();
}, [attachmentData]);
return (
<FormWrapper form={form} isModifyMode={!!bnrSn} {...props}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="85%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>배너명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bnrTtl"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="배너명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>배너구분</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bnrTypeCd"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
upCd="BNRTYPE"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>배너 이미지</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflCd"
render={() => (
<FormItem className="flex flex-col">
<FormControl>
<FileUploader
id="atflCd"
className="!h-auto !max-w-none flex-col !items-start"
accept=".jpeg,.jpg,.png"
onUpload={(e) => {
const files = e.target.files;
if (!files) return;
setCookie("atfl-cd-key", "");
handleUpload(files, {
handleUploadStart: setUpload,
handleProgress,
handleSuccess: setUpload,
handleResponse: handleUploadResponse,
});
}}
customPreview={
<>
<Label htmlFor="atflCd">
<div className="relative h-[27rem] w-[48rem] overflow-hidden rounded-sm border border-border bg-accent">
<div className="relative h-full w-full">
{imageUrl && (
<Image
src={imageUrl}
className="object-contain"
fill
alt="업로드된 이미지"
/>
)}
</div>
<p className="absolute bottom-3 left-0 z-10 p-2 text-base leading-none">
{(upload?.file as File)?.name}
</p>
<Progress
value={progress}
className="absolute bottom-0 left-0 z-10"
/>
</div>
</Label>
<div className="mb-2 text-lg font-medium text-label">
<ul className="ml-10 list-disc">
<li>
이미지 파일(jpg, jpeg, png, gif)만 업로드
가능합니다.
</li>
</ul>
</div>
</>
}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>링크 URL</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="lnkgUrlAddr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="배너에 연결할 링크를 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>링크 열기</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="newWndwYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="newWndwYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="newWndwN" />
<Label htmlFor="newWndwN">현재창</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="newWndwY" />
<Label htmlFor="newWndwY">새창</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>배너 설명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bnrExpln"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
{...field}
value={field.value ?? ""}
className="!text-[1.3rem] !leading-6"
placeholder="배너에 대한 설명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">미사용</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</FormWrapper>
);
}
export { BannerForm };
@@ -1,144 +0,0 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import { Button, DataTable, Input, Label } from "@common/components";
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
import { ENDPOINT } from "@/shared/config";
import { BannerApi } from "../api/banner-service";
import { createColumns } from "../model/banner-table-columns";
import { type Banner, type BannerParams } from "../model/banner-interface";
import { PreviewBannerDialog } from "./preview-banner-dialog";
type PostingTarget = {
pstgTrgtNm: string;
pstgTrgtNo: number;
};
function BannerPage() {
const router = useRouter();
const imageRefs = useRef<Map<number, string>>(new Map());
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
const [bannerQuery, setBannerQuery] = useState<BannerParams>({ bnrTtl: "" });
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
});
const { data: bannerList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.BANNERS],
queryFn: () =>
BannerApi.getBannerList({
...bannerQuery,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
enabled: false,
});
const table = useReactTable({
data: bannerList?.content ?? [],
columns: createColumns(imageRefs),
state: {
pagination,
rowSelection,
},
rowCount: bannerList?.totalElements ?? 0,
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
const handleRowClick = (row: Banner) => {
router.push(`/banner/register/${row.bnrSn}`);
};
const getSelectedBannerImages = () => {
const selectedImages: string[] = [];
table.getFilteredSelectedRowModel().rows.forEach((row) => {
const imageUrl = imageRefs.current.get(row.original.bnrSn);
if (imageUrl) selectedImages.push(imageUrl);
});
return selectedImages;
};
useEffect(() => {
const postingTargets = table
.getFilteredSelectedRowModel()
.rows.map((row) => ({
pstgTrgtNm: row.original.bnrTtl,
pstgTrgtNo: row.original.bnrSn,
}));
if (!postingTargets.length) {
setPostingTargets([]);
return;
}
setPostingTargets(postingTargets);
}, [rowSelection]);
useEffect(() => {
refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
배너명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={bannerQuery.bnrTtl}
onChange={(e) =>
setBannerQuery({ ...bannerQuery, bnrTtl: e.target.value })
}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<div className="flex w-full items-center justify-between">
<h3 className="text-2xl font-medium text-label">배너 목록</h3>
<div className="flex gap-2">
<PreviewBannerDialog images={getSelectedBannerImages()} />
<PostingDialog
postingType={POSTING_TYPE.BANNER}
postingTargets={postingTargets}
/>
<Button onClick={() => router.push("/banner/register")}>
등록
</Button>
</div>
</div>
</div>
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} viewPagination />
</div>
</div>
</div>
);
}
export { BannerPage };
@@ -1,46 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import {
bannerFormSchema,
type BannerFormData,
} from "../model/banner-interface";
import { BannerApi } from "../api/banner-service";
import { BannerForm } from "./banner-form";
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createBanner } = useMutation({
mutationFn: (data: BannerFormData) => BannerApi.createBanner(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<BannerFormData>({
resolver: zodResolver(bannerFormSchema),
defaultValues: {
newWndwYn: "N",
useYn: "Y",
},
});
console.log(form.formState.errors);
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">배너 등록</h3>
</div>
<BannerForm form={form} handleSave={createBanner} />
</div>
);
}
export { CreatePage };
@@ -1,132 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type RowSelectionState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button, DataTable } from "@common/components/ui";
import { PostingApi, type PostPayload } from "@/entities/posting";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ENDPOINT } from "@/shared/config";
import {
bannerFormSchema,
type BannerFormData,
} from "../model/banner-interface";
import { columns } from "../model/posting-table-columns";
import { BannerApi } from "../api/banner-service";
import { BannerForm } from "./banner-form";
function ModifyPage({ sequential }: { sequential: string }) {
const router = useRouter();
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { data: bannerDetail } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.BANNERS, sequential],
queryFn: () => BannerApi.getBannerInfo(sequential),
});
const { data: postHistory } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, sequential],
queryFn: () =>
PostingApi.getPostingHistory({ pstgTrgtNo: Number(sequential) }),
});
const { mutateAsync: updateBanner } = useMutation({
mutationFn: (data: BannerFormData) =>
BannerApi.updateBanner(sequential, data),
onSuccess: () => {
alert("수정되었습니다.");
},
});
const { mutateAsync: deleteBanner } = useMutation({
mutationFn: () => BannerApi.deleteBanner(sequential),
onSuccess: () => {
alert("삭제되었습니다.", router.back);
},
});
const { mutateAsync: savePosting } = useMutation({
mutationFn: (data: PostPayload[]) => PostingApi.savePosting(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, sequential],
});
alert("삭제되었습니다.");
},
});
const form = useForm<BannerFormData>({
resolver: zodResolver(bannerFormSchema),
values: bannerDetail,
});
const table = useReactTable({
data: postHistory?.content ?? [],
columns,
state: {
rowSelection,
},
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
});
const handleDeletePostingHistory = async () => {
const data = table.getFilteredSelectedRowModel().rows.map((row) => ({
...row.original,
mode: "D",
}));
if (await confirm("삭제하시겠습니까?")) {
savePosting(data);
}
};
return (
<div className="mt-6 flex flex-col">
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">배너 수정</h3>
</div>
<BannerForm
form={form}
bnrSn={Number(sequential)}
handleSave={updateBanner}
handleDelete={deleteBanner}
/>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">사이트 게시 이력</h3>
</div>
<div className="card">
<div className="mb-4 flex justify-end">
<Button
size="sm"
color="red"
disabled={!table.getFilteredSelectedRowModel().rows.length}
onClick={handleDeletePostingHistory}
>
이력삭제
</Button>
</div>
<DataTable table={table} />
</div>
</div>
</div>
);
}
export { ModifyPage };
@@ -1,60 +0,0 @@
"use client";
import Image from "next/image";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
Button,
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@common/components";
type PreviewBannerDialogProps = {
images: string[];
};
function PreviewBannerDialog({ images }: PreviewBannerDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>
<Button color="white" disabled={!images.length}>
<i className="diveicon di-display" />
미리보기
</Button>
</DialogTrigger>
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>배너 이미지 미리보기</DialogTitle>
</DialogHeader>
<div className="mt-4 px-12">
<Carousel>
<CarouselContent>
{images.map((image, i) => (
<CarouselItem key={`banner-${i}`} className="basis-1/3">
<div className="relative flex aspect-video items-center justify-center">
<Image
src={image}
className="object-contain"
fill
alt={`배너 이미지 ${i + 1}`}
/>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="!bg-accent text-accent-foreground" />
<CarouselNext className="!bg-accent text-accent-foreground" />
</Carousel>
</div>
</DialogContent>
</Dialog>
);
}
export { PreviewBannerDialog };
@@ -1,3 +0,0 @@
export { BbsPage } from "./ui/bbs-page";
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
@@ -1,41 +0,0 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "@common/components/ui";
import type { Board } from "@/entities/board/model/board-interface";
export const columns: ColumnDef<Board>[] = [
{
id: "no",
header: "NO",
cell: ({ row }) => row.index + 1,
},
{
accessorKey: "bbsNm",
header: "게시판명",
},
{
accessorKey: "bbsTypeNm",
header: "게시판유형",
},
{
accessorKey: "mdfrNm",
header: "최종수정자",
},
{
accessorKey: "mdfcnDt",
header: "최종수정일",
cell: ({ row }) =>
row.original.mdfcnDt
? dayjs(row.original.mdfcnDt).format("YYYY-MM-DD")
: "",
},
{
accessorKey: "useYn",
header: "사용여부",
},
{
id: "actions",
header: "관리",
cell: () => <Button size="sm">관리</Button>,
},
];
@@ -1,447 +0,0 @@
"use client";
import type { UseFormReturn } from "react-hook-form";
import {
Input,
Label,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { FormWrapper } from "@/widgets/form-wrapper";
import { CodeSelect } from "@/entities/code";
import type { Board, BoardFormData } from "@/entities/board";
import { FormControl, FormField, FormItem } from "@/shared/ui/form";
type BbsFormProps = {
form: UseFormReturn<BoardFormData>;
bbsId?: string;
handleSave: (data: BoardFormData) => Promise<Board>;
};
function BbsForm({ form, bbsId, ...props }: BbsFormProps) {
return (
<FormWrapper form={form} isModifyMode={!!bbsId} {...props}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="85%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>게시판명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bbsNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="게시판명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>게시판 설명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bbsExpln"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="게시판설명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>게시판 유형</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bbsTypeCd"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormControl>
<CodeSelect
{...field}
upCd="BBSTYPE"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>참조 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="rfrncUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="rfrncUseY" />
<Label htmlFor="rfrncUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="rfrncUseN" />
<Label htmlFor="rfrncUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="atflUseY" />
<Label htmlFor="atflUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="atflUseN" />
<Label htmlFor="atflUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일 최대 개수</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflMaxCnt"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="number"
min={0}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (isNaN(Number(value))) return;
field.onChange(value === "" ? null : Number(value));
}}
className="w-[12rem] !text-[1.3rem]"
placeholder="최대 개수 입력"
disabled={form.watch("atflUseYn") === "N"}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일 최대 크기</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflMaxFileSz"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="number"
min={0}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (isNaN(Number(value))) return;
field.onChange(value === "" ? null : Number(value));
}}
className="w-[12rem] !text-[1.3rem]"
placeholder="최대 크기 입력"
disabled={form.watch("atflUseYn") === "N"}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>답글 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="ansUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="ansUseY" />
<Label htmlFor="ansUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="ansUseN" />
<Label htmlFor="ansUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>답글 사용 권한</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="ansUseAuthrt"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={form.watch("ansUseYn") === "N"}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">관리자</SelectItem>
<SelectItem value="M">회원</SelectItem>
<SelectItem value="N">일반</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>댓글 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="cmntUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="cmntUseY" />
<Label htmlFor="cmntUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="cmntUseN" />
<Label htmlFor="cmntUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>게시물평가 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="dgstfnEvlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="dgstfnEvlUseY" />
<Label htmlFor="dgstfnEvlUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="dgstfnEvlUseN" />
<Label htmlFor="dgstfnEvlUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>팝업 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="popupUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="popupUseY" />
<Label htmlFor="popupUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="popupUseN" />
<Label htmlFor="popupUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>SMS알림 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="smsUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="smsUseY" />
<Label htmlFor="smsUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="smsUseN" />
<Label htmlFor="smsUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>상단고정 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="upndFixUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="upndFixUseY" />
<Label htmlFor="upndFixUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="upndFixUseN" />
<Label htmlFor="upndFixUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>게시기간 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="pstgPrdUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="pstgPrdUseY" />
<Label htmlFor="pstgPrdUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="pstgPrdUseN" />
<Label htmlFor="pstgPrdUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</FormWrapper>
);
}
export { BbsForm };
@@ -1,145 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import {
Button,
DataTable,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import { BoardApi, type BoardParams } from "@/entities/board";
import { ENDPOINT } from "@/shared/config";
import { type Board } from "@/entities/board";
import { columns } from "../model/table-columns";
function BbsPage() {
const router = useRouter();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [boardParams, setBoardParams] = useState<BoardParams>({
bbsTypeCd: "",
bbsNm: "",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: boardList, refetch } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.BBS],
queryFn: () =>
BoardApi.getBoardList({
...boardParams,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
enabled: false,
});
const table = useReactTable({
data: boardList?.content ?? [],
columns,
state: {
pagination,
rowSelection,
},
rowCount: boardList?.totalElements ?? 0,
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
const handleRowClick = (row: Board) => {
router.push(`/bbs/register/${row.bbsId}`);
};
useEffect(() => {
refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="bbsType" className="mr-2 text-label">
게시판 유형
</Label>
<CodeSelect
upCd="BBSTYPE"
value={boardParams.bbsTypeCd}
onValueChange={(value) =>
setBoardParams({ ...boardParams, bbsTypeCd: value })
}
/>
<Label htmlFor="bbsNm" className="ml-4 mr-2 text-label">
게시판명
</Label>
<Input
id="bbsNm"
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={boardParams.bbsNm}
onChange={(e) =>
setBoardParams({ ...boardParams, bbsNm: e.target.value })
}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<div className="flex w-full items-center justify-between">
<h3 className="text-2xl font-medium text-label">게시판 목록</h3>
<div className="flex gap-2">
<Button onClick={() => router.push("/bbs/register")}>등록</Button>
<Select
value={table.getState().pagination.pageSize.toString()}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-[3.2rem] w-[12rem]">
<SelectValue placeholder="페이지 크기" />
</SelectTrigger>
<SelectContent>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={pageSize.toString()}>
{pageSize}개씩 보기
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} viewPagination />
</div>
</div>
</div>
);
}
export { BbsPage };
@@ -1,55 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import {
BoardApi,
boardFormSchema,
type BoardFormData,
} from "@/entities/board";
import { BbsForm } from "./bbs-form";
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createBoard } = useMutation({
mutationFn: (data: BoardFormData) => BoardApi.createBoard(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<BoardFormData>({
resolver: zodResolver(boardFormSchema),
defaultValues: {
bbsTypeCd: "",
bbsNm: "",
bbsExpln: "",
rfrncUseYn: "Y",
atflUseYn: "Y",
ansUseYn: "Y",
cmntUseYn: "Y",
dgstfnEvlUseYn: "Y",
smsUseYn: "Y",
upndFixUseYn: "Y",
pstgPrdUseYn: "Y",
popupUseYn: "Y",
useYn: "Y",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">게시판 등록</h3>
</div>
<BbsForm form={form} handleSave={createBoard} />
</div>
);
}
export { CreatePage };
@@ -1,45 +0,0 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ENDPOINT } from "@/shared/config";
import {
BoardApi,
boardFormSchema,
type BoardFormData,
} from "@/entities/board";
import { BbsForm } from "./bbs-form";
function ModifyPage({ bbsId }: { bbsId: string }) {
const { setMessage: alert } = useAlertStore((state) => state);
const { data: boardInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.BBS, bbsId],
queryFn: () => BoardApi.getBoardInfo(bbsId),
});
const { mutateAsync: updateBoard } = useMutation({
mutationFn: (data: BoardFormData) => BoardApi.updateBoard(bbsId, data),
onSuccess: () => {
alert("수정되었습니다.");
},
});
const form = useForm<BoardFormData>({
resolver: zodResolver(boardFormSchema),
values: boardInfo,
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">게시판 수정</h3>
</div>
<BbsForm form={form} bbsId={bbsId} handleSave={updateBoard} />
</div>
);
}
export { ModifyPage };
@@ -1,3 +0,0 @@
export { ContentsPage } from "./ui/contents-page";
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
@@ -1,68 +0,0 @@
import dayjs from "dayjs";
import { createColumnHelper } from "@tanstack/react-table";
import type { Contents } from "@/entities/content";
const columnHelper = createColumnHelper<Contents>();
export const columns = [
columnHelper.display({
id: "no",
header: "NO",
cell: ({ row }) => row.index + 1,
}),
columnHelper.accessor("contsTtl", {
id: "contsTtl",
header: "콘텐츠명",
}),
columnHelper.group({
id: "workInfo",
header: "작성정보",
columns: [
columnHelper.accessor("workContsVer", {
header: "작성버전",
cell: ({ row }) =>
row.original.workContsVer ? `rev. ${row.original.workContsVer}` : "",
}),
columnHelper.accessor("workAprvNm", {
header: "작성상태",
}),
],
}),
columnHelper.group({
id: "aprvInfo",
header: "승인정보",
columns: [
columnHelper.accessor("aprvContsVer", {
header: "승인버전",
cell: ({ row }) =>
row.original.aprvContsVer ? `rev. ${row.original.aprvContsVer}` : "",
}),
columnHelper.accessor("autzrNm", {
header: "승인자",
}),
columnHelper.accessor("aprvDt", {
header: "승인일자",
cell: ({ row }) =>
row.original.aprvDt
? dayjs(row.original.aprvDt).format("YYYY-MM-DD")
: "",
}),
],
}),
columnHelper.accessor("useYn", {
id: "useYn",
header: "사용여부",
}),
columnHelper.accessor("mdfrNm", {
id: "mdfrNm",
header: "최종수정자",
}),
columnHelper.accessor("mdfcnDt", {
id: "mdfcnDt",
header: "최종수정일",
cell: ({ row }) =>
row.original.mdfcnDt
? dayjs(row.original.mdfcnDt).format("YYYY-MM-DD")
: "",
}),
];
@@ -1,268 +0,0 @@
import dynamic from "next/dynamic";
import { type UseFormReturn } from "react-hook-form";
import {
Input,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { FormWrapper } from "@/widgets/form-wrapper";
import { FormItem, FormControl, FormField } from "@/shared/ui/form";
import type { ContentsFormData } from "@/entities/content";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { CodeApi } from "@/entities/code/api/code-service";
import Image from "next/image";
const Editor = dynamic(
() => import("@/entities/editor").then((module) => module.Editor),
{ ssr: false },
);
interface ContentsFormProps {
form: UseFormReturn<ContentsFormData>;
contsSn?: number;
isEditable?: boolean;
isContsTtlDisabled?: boolean;
handleSave: (data: ContentsFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
}
function ContentsForm({
form,
contsSn,
isEditable,
isContsTtlDisabled,
...props
}: ContentsFormProps) {
const { data: cclCode } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "CCLCD"],
queryFn: () => CodeApi.getCode("CCLCD"),
});
const { data: koglCode } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "KOGLCD"],
queryFn: () => CodeApi.getCode("KOGLCD"),
});
return (
<FormWrapper form={form} isModifyMode={!!contsSn} {...props}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="85%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>콘텐츠 제목</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="contsTtl"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="콘텐츠 제목을 입력하십시오."
disabled={!isEditable || isContsTtlDisabled}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>콘텐츠 내용</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="contsCn"
render={({ field }) => (
<FormItem>
<FormControl>
<Editor
value={field.value ?? ""}
onEditorChange={field.onChange}
disabled={!isEditable}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>CCL</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="cclCd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="cclCd"
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={!isEditable}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="" id="ccl-none" />
<Label
htmlFor="ccl-none"
className="whitespace-nowrap"
>
해당없음
</Label>
</div>
{cclCode?.map((code) => (
<div
className="flex items-center space-x-2"
key={code.lwpoCd}
>
<RadioGroupItem
value={code.lwpoCd}
id={`ccl-${code.lwpoCd}`}
/>
<Label htmlFor={`ccl-${code.lwpoCd}`}>
<Image
src={`/images/${code.unit}`}
alt="CCL"
width={77}
height={27}
/>
</Label>
</div>
))}
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>공공누리</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="koglCd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="koglCd"
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={!isEditable}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="" id="koglNone" />
<Label
htmlFor="koglNone"
className="whitespace-nowrap"
>
해당없음
</Label>
</div>
{koglCode?.map((code) => (
<div
className="flex items-center space-x-2"
key={code.lwpoCd}
>
<RadioGroupItem
value={code.lwpoCd}
id={`kogl-${code.lwpoCd}`}
/>
<Label htmlFor={`kogl-${code.lwpoCd}`}>
<div className="relative h-[2.7rem] w-[11rem]">
<Image
src={`/images/${code.unit}`}
alt="공공누리"
fill
className="object-contain"
/>
</div>
</Label>
</div>
))}
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>태그</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="tag"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="태그를 입력하십시오."
disabled={!isEditable}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={!isEditable}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</FormWrapper>
);
}
export { ContentsForm };
@@ -1,105 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type PaginationState,
} from "@tanstack/react-table";
import { Button, DataTable, Input, Label } from "@common/components";
import {
ContentsApi,
type Contents,
type ContentsParams,
} from "@/entities/content";
import { ENDPOINT } from "@/shared/config";
import { columns } from "../model/table-columns";
function ContentsPage() {
const router = useRouter();
const [contentsQuery, setContentsQuery] = useState<ContentsParams>({
contsTtl: "",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
});
const { data: contentsList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS],
queryFn: () =>
ContentsApi.getContentsList({
...contentsQuery,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
enabled: false,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
const handleRowClick = (row: Contents) => {
router.push(`/contents/register/${row.contsSn}`);
};
const table = useReactTable({
data: contentsList?.content ?? [],
columns,
rowCount: contentsList?.totalElements ?? 0,
state: {
pagination,
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
});
useEffect(() => {
refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
콘텐츠명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={contentsQuery.contsTtl}
onChange={(e) => setContentsQuery({ contsTtl: e.target.value })}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">콘텐츠 목록</h3>
<Button onClick={() => router.push("/contents/register")}>
등록
</Button>
</div>
<div className="card !m-[1.2rem] overflow-auto !p-0">
<DataTable
table={table}
onRowClick={handleRowClick}
variant="secondary"
viewPagination
/>
</div>
</div>
</div>
);
}
export { ContentsPage };
@@ -1,43 +0,0 @@
"use client";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import {
ContentsApi,
contentsFormSchema,
type ContentsFormData,
} from "@/entities/content";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ContentsForm } from "./contents-form";
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createContents } = useMutation({
mutationFn: (data: ContentsFormData) => ContentsApi.createContents(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<ContentsFormData>({
resolver: zodResolver(contentsFormSchema),
defaultValues: {
useYn: "Y",
},
});
return (
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">콘텐츠 등록</h3>
</div>
<ContentsForm form={form} handleSave={createContents} />
</div>
);
}
export { CreatePage };
@@ -1,182 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import {
CONTENTS_APPROVE_STATUS,
ContentsApi,
contentsFormSchema,
type ContentsFormData,
} from "@/entities/content";
import { ContentsForm } from "./contents-form";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Button,
} from "@common/components/ui";
function ModifyPage({ sequential }: { sequential: string }) {
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const contsSn = Number(sequential);
const [contsVer, setContsVer] = useState(0);
const { data: contentsVersionList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn],
queryFn: () =>
ContentsApi.getContentsVersionList({
contsSn: contsSn,
page: 0,
size: 100,
}),
});
const { data: contentsDetail } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsVer],
queryFn: () => ContentsApi.getLatestContentsInfo(contsSn, contsVer),
enabled: !!contsVer,
});
const { mutateAsync: createContents } = useMutation({
mutationFn: (data: ContentsFormData) => ContentsApi.createContents(data),
onSuccess: () => {
setContsVer(0);
alert("수정되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn],
});
},
});
const { mutateAsync: updateContents } = useMutation({
mutationFn: (data: ContentsFormData) =>
ContentsApi.updateContents(contsSn, contsVer, data),
onSuccess: () => {
alert("수정되었습니다.");
},
});
const { mutateAsync: deleteContents } = useMutation({
mutationFn: () => ContentsApi.deleteContents(contsSn, contsVer),
onSuccess: () => {
setContsVer(0);
alert("삭제되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn],
});
},
});
const { mutateAsync: requestApprove } = useMutation({
mutationFn: () => ContentsApi.requestApprove(contsSn, contsVer),
onSuccess: () => {
alert("승인 요청이 완료되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS],
});
},
});
const { mutateAsync: approveContents } = useMutation({
mutationFn: () => ContentsApi.approveContents(contsSn, contsVer),
onSuccess: () => {
alert("승인되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS],
});
},
});
const form = useForm<ContentsFormData>({
resolver: zodResolver(contentsFormSchema),
values: contentsDetail,
});
const approveStatus = contentsDetail?.aprvCd;
const isContsTtlDisabled =
(contentsVersionList?.content?.[0]?.contsVer ?? 0) > 1;
const onRequestApprove = async () => {
const message =
"승인 요청하면 더이상 내용을 수정할 수 없습니다.\n요청하시겠습니까?";
if (await confirm(message)) requestApprove();
};
const onApprove = async () => {
const message = "승인하시겠습니까?";
if (await confirm(message)) approveContents();
};
useEffect(() => {
if (contsVer) return;
if (!contentsVersionList?.content?.length) return;
const latestContent = contentsVersionList?.content[0];
if (!latestContent) return;
setContsVer(latestContent?.contsVer ?? 0);
}, [contentsVersionList?.content]);
return (
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">템플릿 수정</h3>
<div className="flex items-center gap-2">
<Select
value={contsVer.toString()}
onValueChange={(value) => {
setContsVer(Number(value));
}}
>
<SelectTrigger className="w-[28rem]">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{contentsVersionList?.content?.map((item) => (
<SelectItem
key={item.contsVer}
value={item.contsVer.toString()}
>
{item.contsCn}
</SelectItem>
))}
</SelectContent>
</Select>
{approveStatus === CONTENTS_APPROVE_STATUS.DRAFT && (
<Button onClick={onRequestApprove}>승인요청</Button>
)}
{approveStatus === CONTENTS_APPROVE_STATUS.REQUESTED && (
<>
<Button onClick={onApprove}>승인</Button>
<Button color="gray">반려</Button>
</>
)}
</div>
</div>
<ContentsForm
form={form}
contsSn={contsSn}
isEditable={approveStatus !== CONTENTS_APPROVE_STATUS.REQUESTED}
isContsTtlDisabled={isContsTtlDisabled}
handleSave={
approveStatus === CONTENTS_APPROVE_STATUS.APPROVED
? createContents
: updateContents
}
handleDelete={deleteContents}
/>
</div>
);
}
export { ModifyPage };
@@ -1 +0,0 @@
export { DashboardPage } from "./ui/dashboard-page";
@@ -1,5 +0,0 @@
function DashboardPage() {
return <></>;
}
export { DashboardPage };
@@ -1 +0,0 @@
export { FilePage } from "./ui/file-page";
@@ -1,77 +0,0 @@
"use client";
import dayjs from "dayjs";
import { type ColumnDef } from "@tanstack/react-table";
import { Button } from "@common/components";
import type { Attachment } from "@/entities/attachment";
export const createColumns = (
handleDeleteAttachment: (atflId: string) => void,
): ColumnDef<Attachment>[] => [
{
id: "no",
header: "NO",
cell: (info) => info.row.index + 1,
},
{
accessorKey: "atflId",
header: "파일ID",
},
{
accessorKey: "atflSn",
header: "파일순번",
},
{
accessorKey: "orgnlFileNm",
header: "파일명",
},
{
accessorKey: "atflFlieSz",
header: "파일크기",
cell: ({ row }) => {
const size = row.getValue("atflFlieSz");
return size ? formatFileSize(Number(size)) : "0 Bytes";
},
},
{
accessorKey: "dwnldNocs",
header: "다운로드수",
},
{
accessorKey: "regDt",
header: "등록일시",
cell: ({ row }) => {
const date = row.getValue("regDt") as string;
return date ? dayjs(date).format("YYYY-MM-DD") : "";
},
},
{
accessorKey: "delYn",
header: "삭제여부",
},
{
id: "delete",
header: "삭제",
cell: ({ row }) => (
<Button
size="sm"
color="red"
onClick={() => handleDeleteAttachment(row.original.atflId)}
>
삭제
</Button>
),
},
];
const formatFileSize = (bytes: number): string => {
console.log("bytes: ", bytes);
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
@@ -1,118 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type PaginationState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button, DataTable, Input, Label } from "@common/components";
import { AttachmentApi, type AttachmentParams } from "@/entities/attachment";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ENDPOINT } from "@/shared/config";
import { createColumns } from "../model/table-columns";
function FilePage() {
const { confirm } = useConfirm();
const { setMessage: alert } = useAlertStore((state) => state);
const [attachmentQuery, setAttachmentQuery] = useState<AttachmentParams>({
keyword: "",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: attachmentsList, refetch } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS],
queryFn: () =>
AttachmentApi.getAttachmentsList({
...attachmentQuery,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
enabled: false,
});
const { mutate: deleteAttachment } = useMutation({
mutationFn: (atflId: string) => AttachmentApi.deleteAttachment(atflId),
onSuccess: () => {
refetch();
alert("삭제되었습니다.");
},
});
const handleDeleteAttachment = async (atflId: string) => {
if (await confirm("삭제하시겠습니까?")) {
deleteAttachment(atflId);
}
};
const table = useReactTable({
data: attachmentsList?.content ?? [],
columns: createColumns(handleDeleteAttachment),
rowCount: attachmentsList?.totalElements ?? 0,
state: {
pagination,
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
});
const handleAttachmentQueryChange =
(key: keyof AttachmentParams) => (value: string) => {
setAttachmentQuery({ ...attachmentQuery, [key]: value });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
useEffect(() => {
refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex gap-2 p-2">
<div className="flex space-x-6">
<div className="flex items-center gap-2">
<Label htmlFor="keyword" className="text-label">
파일명
</Label>
<Input
id="keyword"
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={attachmentQuery.keyword || ""}
onChange={(e) =>
handleAttachmentQueryChange("keyword")(e.target.value)
}
placeholder="파일명을 입력하세요"
/>
</div>
</div>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">파일 목록</h3>
</div>
<div className="card !m-[1.2rem] overflow-auto !p-0">
<DataTable table={table} viewPagination />
</div>
</div>
</div>
);
}
export { FilePage };
@@ -1,47 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
Image,
ImageFormData,
ImageList,
ImageParams,
ImageService,
} from "../model/image-interface";
export class ImageApiService implements ImageService {
public async getImageList(params: ImageParams): Promise<ImageList> {
const response = await client.get<ImageList>(
`${ENDPOINT.CMS_SERVICE.IMAGES}`,
{
params,
},
);
return response.data;
}
public async getImageInfo(imgSn: string): Promise<Image> {
const response = await client.get<Image>(
`${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`,
);
return response.data;
}
public async createImage(data: ImageFormData): Promise<void> {
const response = await client.post<void>(ENDPOINT.CMS_SERVICE.IMAGES, data);
return response.data;
}
public async updateImage(imgSn: string, data: ImageFormData): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`,
data,
);
return response.data;
}
public async deleteImage(imgSn: string): Promise<void> {
await client.delete(`${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`);
}
}
export const ImageApi = new ImageApiService();
@@ -1,3 +0,0 @@
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
export { ImagePage } from "./ui/image-page";
@@ -1,52 +0,0 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export const IMAGE_TYPE = {
IMGSUB: "IMGSUB",
IMGMAIN: "IMGMAIN",
IMGLOGIN: "IMGLOGIN",
} as const;
export type ImageType = (typeof IMAGE_TYPE)[keyof typeof IMAGE_TYPE];
export interface ImageParams extends PageableParams {
keyword: string;
imgTypeCd?: string;
useYn?: string;
}
export interface Image {
imgSn: number;
imgTypeCd: ImageType;
imgTypeNm: string;
imgTtl: string;
lnkgUrlAddr: string;
atflYn: string;
sortSeq: number;
atflCd: string;
useYn: string;
mdfcnDt: string;
mdFr: string;
mdfrNm: string;
}
export const imageFormSchema = z.object({
imgTtl: z.string(),
sortSeq: z.number(),
imgTypeCd: z.string(),
atflYn: z.string(),
atflCd: z.string().nullish(),
lnkgUrlAddr: z.string().nullish(),
useYn: z.string(),
});
export type ImageFormData = z.infer<typeof imageFormSchema>;
export type ImageList = PageableList<Image>;
export interface ImageService {
getImageList: (params: ImageParams) => Promise<ImageList>;
getImageInfo: (imgSn: string) => Promise<Image>;
createImage: (data: ImageFormData) => Promise<void>;
updateImage: (imgSn: string, data: ImageFormData) => Promise<void>;
deleteImage: (imgSn: string) => Promise<void>;
}
@@ -1,73 +0,0 @@
"use client";
import dayjs from "dayjs";
import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import { type Image } from "./image-interface";
import { IMAGE_TYPE } from "./image-interface";
export const columns: ColumnDef<Image>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
accessorKey: "imgSn",
header: "NO",
cell: ({ row }) => row.index + 1,
},
{
accessorKey: "imgTypeCd",
header: "이미지구분",
cell: ({ row }) => {
const type = row.getValue("imgTypeCd");
switch (type) {
case IMAGE_TYPE.IMGSUB:
return "서브이미지";
case IMAGE_TYPE.IMGMAIN:
return "메인이미지";
case IMAGE_TYPE.IMGLOGIN:
return "로그인이미지";
default:
return type;
}
},
},
{
accessorKey: "imgTtl",
header: "이미지명",
},
{
accessorKey: "useYn",
header: "사용여부",
},
{
accessorKey: "mdfrNm",
header: "최종수정자",
},
{
accessorKey: "mdfcnDt",
header: "최종수정일",
cell: ({ row }) => {
const date = row.getValue("mdfcnDt") as string;
return date ? dayjs(date).format("YYYY-MM-DD") : "";
},
},
];
@@ -1,44 +0,0 @@
"use client";
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import type { Post } from "@/entities/posting";
export const columns: ColumnDef<Post>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
accessorKey: "siteNm",
header: "사이트",
},
{
accessorKey: "pstgPrdSeNm",
header: "게시기간",
cell: ({ row }) => {
const pstgPrdSeNm = row.original.pstgPrdSeNm;
const pstgBgngDt = row.original.pstgBgngDt;
const pstgEndDt = row.original.pstgEndDt;
return `${pstgPrdSeNm} (${dayjs(pstgBgngDt).format("YYYY-MM-DD")} ~ ${dayjs(pstgEndDt).format("YYYY-MM-DD")})`;
},
},
];
@@ -1,40 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { imageFormSchema, type ImageFormData } from "../model/image-interface";
import { ImageApi } from "../api/image-service";
import { ImageForm } from "./image-form";
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createImage } = useMutation({
mutationFn: (data: ImageFormData) => ImageApi.createImage(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<ImageFormData>({
resolver: zodResolver(imageFormSchema),
defaultValues: {
atflYn: "Y",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 등록</h3>
</div>
<ImageForm form={form} handleSave={createImage} />
</div>
);
}
export { CreatePage };
@@ -1,307 +0,0 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { UseFormReturn } from "react-hook-form";
import type { HttpResponse } from "tus-js-client";
import {
FileUploader,
Input,
Label,
Progress,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { FormWrapper } from "@/widgets/form-wrapper";
import { CodeSelect } from "@/entities/code";
import { AttachmentApi } from "@/entities/attachment";
import { ENDPOINT } from "@/shared/config";
import { useTus } from "@/shared/lib/use-tus";
import { FormControl, FormField, FormItem } from "@/shared/ui/form";
import { useAttachmentStore } from "@/shared/lib/use-attachment-store";
import type { ImageFormData } from "../model/image-interface";
type ImageFormProps = {
form: UseFormReturn<ImageFormData>;
imgSn?: number;
handleSave: (data: ImageFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
};
function ImageForm({ form, imgSn, ...props }: ImageFormProps) {
const atflYn = form.watch("atflYn");
const atflCd = form.watch("atflCd");
const {
file: uploadFile,
response: uploadResponse,
progress,
setFile,
} = useTus();
const { setAttachmentFileCode } = useAttachmentStore((state) => state);
const [imageUrl, setImageUrl] = useState("");
const { data: attachmentData } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd],
queryFn: () => AttachmentApi.getAttachmentsInfo(atflCd!),
select: (data) => data?.[data.length - 1],
enabled: !!atflCd,
});
const handleUploadResponse = async (res: HttpResponse) => {
const fileInfo = res.getHeader("X-File-Info");
if (fileInfo === undefined || fileInfo === null) return;
const { atflCd, atflId } = JSON.parse(fileInfo) ?? {};
if (atflCd) form.setValue("atflCd", atflCd);
if (atflId) setImageUrl(await AttachmentApi.getImageUrl(atflId));
if (attachmentData)
await AttachmentApi.deleteAttachmentByAtflCd(attachmentData.atflCd);
};
useEffect(() => {
if (!uploadResponse) return;
handleUploadResponse(uploadResponse);
}, [uploadResponse]);
useEffect(() => {
(async () => {
setImageUrl(
await AttachmentApi.getImageUrlByAtflCd(attachmentData?.atflCd ?? ""),
);
})();
}, [attachmentData]);
return (
<FormWrapper form={form} isModifyMode={!!imgSn} {...props}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="85%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>이미지명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="imgTtl"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="이미지명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>정렬순서</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="sortSeq"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="number"
min={0}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (isNaN(Number(value))) return;
field.onChange(Number(value));
}}
className="w-[12rem] !text-[1.3rem]"
placeholder="정렬순서 입력"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>이미지유형</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="imgTypeCd"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
upCd="IMGTYPE"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="atflYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="atflY" />
<Label htmlFor="atflY">이미지파일</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="atflN" />
<Label htmlFor="atflN">외부링크</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{atflYn === "Y" && (
<TableRow>
<TableHead>이미지파일</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflCd"
render={() => (
<FormItem className="flex flex-col">
<FormControl>
<FileUploader
id="atflCd"
className="!h-auto !max-w-none flex-col !items-start"
accept=".jpeg,.jpg,.png"
onUpload={(e) => {
const files = e.target.files;
if (!files) return;
setAttachmentFileCode("");
setFile(files[0] ?? null);
}}
customPreview={
<>
<Label htmlFor="atflCd">
<div className="relative h-[27rem] w-[48rem] overflow-hidden rounded-sm border border-border bg-accent">
<div className="relative h-full w-full">
{imageUrl && (
<Image
src={imageUrl}
className="object-contain"
fill
alt="업로드된 이미지"
/>
)}
</div>
<p className="absolute bottom-3 left-0 z-10 p-2 text-base leading-none">
{uploadFile?.name}
</p>
<Progress
value={progress}
className="absolute bottom-0 left-0 z-10"
/>
</div>
</Label>
<div className="mb-2 text-lg font-medium text-label">
<ul className="ml-10 list-disc">
<li>권장 사이즈: 1920x500</li>
<li>파일 최대 크기: 2MB</li>
<li>확장자: jpg, jpeg, png 가능</li>
</ul>
</div>
</>
}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
{atflYn === "N" && (
<TableRow>
<TableHead>외부링크</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="lnkgUrlAddr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="외부링크를 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
<TableRow>
<TableHead>사용여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">미사용</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</FormWrapper>
);
}
export { ImageForm };
@@ -1,132 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import { Button, DataTable, Input, Label } from "@common/components";
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
import { ENDPOINT } from "@/shared/config";
import { ImageApi } from "../api/image-service";
import { columns } from "../model/image-table-columns";
import { type Image, type ImageParams } from "../model/image-interface";
type PostingTarget = {
pstgTrgtNm: string;
pstgTrgtNo: number;
};
function ImagePage() {
const router = useRouter();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
const [imageQuery, setImageQuery] = useState<ImageParams>({ keyword: "" });
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
});
const { data: imageList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES],
queryFn: () =>
ImageApi.getImageList({
...imageQuery,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
enabled: false,
});
const table = useReactTable({
data: imageList?.content ?? [],
columns,
state: {
pagination,
rowSelection,
},
rowCount: imageList?.totalElements ?? 0,
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
const handleRowClick = (row: Image) => {
router.push(`/image/register/${row.imgSn}`);
};
useEffect(() => {
const postingTargets = table
.getFilteredSelectedRowModel()
.rows.map((row) => ({
pstgTrgtNm: row.original.imgTtl,
pstgTrgtNo: row.original.imgSn,
}));
if (!postingTargets.length) {
setPostingTargets([]);
return;
}
setPostingTargets(postingTargets);
}, [rowSelection]);
useEffect(() => {
refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
이미지명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={imageQuery.keyword}
onChange={(e) =>
setImageQuery({ ...imageQuery, keyword: e.target.value })
}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<div className="flex w-full items-center justify-between">
<h3 className="text-2xl font-medium text-label">이미지 목록</h3>
<div className="flex gap-2">
<PostingDialog
postingType={POSTING_TYPE.IMAGE}
postingTargets={postingTargets}
/>
<Button onClick={() => router.push("/image/register")}>
등록
</Button>
</div>
</div>
</div>
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} viewPagination />
</div>
</div>
</div>
);
}
export { ImagePage };
@@ -1,128 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type RowSelectionState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button, DataTable } from "@common/components/ui";
import { PostingApi, type PostPayload } from "@/entities/posting";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ENDPOINT } from "@/shared/config";
import { imageFormSchema, type ImageFormData } from "../model/image-interface";
import { columns } from "../model/posting-table-columns";
import { ImageApi } from "../api/image-service";
import { ImageForm } from "./image-form";
function ModifyPage({ sequential }: { sequential: string }) {
const router = useRouter();
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { data: imageDetail } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES, sequential],
queryFn: () => ImageApi.getImageInfo(sequential),
});
const { data: postHistory } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, sequential],
queryFn: () =>
PostingApi.getPostingHistory({ pstgTrgtNo: Number(sequential) }),
});
const { mutateAsync: updateImage } = useMutation({
mutationFn: (data: ImageFormData) => ImageApi.updateImage(sequential, data),
onSuccess: () => {
alert("수정되었습니다.");
},
});
const { mutateAsync: deleteImage } = useMutation({
mutationFn: () => ImageApi.deleteImage(sequential),
onSuccess: () => {
alert("삭제되었습니다.", router.back);
},
});
const { mutateAsync: savePosting } = useMutation({
mutationFn: (data: PostPayload[]) => PostingApi.savePosting(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, sequential],
});
alert("삭제되었습니다.");
},
});
const form = useForm<ImageFormData>({
resolver: zodResolver(imageFormSchema),
values: imageDetail,
});
const table = useReactTable({
data: postHistory?.content ?? [],
columns,
state: {
rowSelection,
},
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
});
const handleDeletePostingHistory = async () => {
const data = table.getFilteredSelectedRowModel().rows.map((row) => ({
...row.original,
mode: "D",
}));
if (await confirm("삭제하시겠습니까?")) {
await savePosting(data);
}
};
return (
<div className="mt-6 flex flex-col">
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 수정</h3>
</div>
<ImageForm
form={form}
imgSn={Number(sequential)}
handleSave={updateImage}
handleDelete={deleteImage}
/>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">사이트 게시 이력</h3>
</div>
<div className="card">
<div className="mb-4 flex justify-end">
<Button
size="sm"
color="red"
disabled={!table.getFilteredSelectedRowModel().rows.length}
onClick={handleDeletePostingHistory}
>
이력삭제
</Button>
</div>
<DataTable table={table} />
</div>
</div>
</div>
);
}
export { ModifyPage };
@@ -1,65 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
Menu,
MenuFormData,
MenuService,
TreeMenu,
TreeNode,
} from "../model/menu-interface";
export class MenuApiService implements MenuService {
public async getMenuInfo(menuCd: number): Promise<Menu> {
const response = await client.get<Menu>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`,
);
return response.data;
}
public async createMenu(data: MenuFormData): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}`,
data,
);
return response.data;
}
public async updateMenu(
menuCd: number,
data: Omit<MenuFormData, "siteId">,
): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`,
data,
);
return response.data;
}
public async deleteMenu(menuCd: number): Promise<void> {
await client.delete(`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`);
}
public async getTreeMenuList(siteId: string): Promise<TreeMenu[]> {
const response = await client.get<TreeMenu[]>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`,
);
return response.data;
}
public async updateTreeMenu(siteId: string, data: TreeNode[]): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`,
data,
);
return response.data;
}
public async updateMenuName(menuCd: number, name: string): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}/${name}`,
);
return response.data;
}
}
export const MenuApi = new MenuApiService();
@@ -1 +0,0 @@
export { MenuPage } from "./ui/menu-page";
@@ -1,171 +0,0 @@
import type { DropOptions, NodeModel } from "@minoru/react-dnd-treeview";
import type {
TreeUserOrganization,
TreeUser,
} from "@/entities/user-organization";
import type { TreeMenu, TreeNode } from "../model/menu-interface";
export const generateMenuTreeData = (
data: TreeMenu[],
): NodeModel<TreeMenu>[] => {
return data.reduce<NodeModel<TreeMenu>[]>((acc, menu) => {
const node: NodeModel<TreeMenu> = {
id: menu.menuCd ?? 0,
parent: menu.parentId ?? 0,
text: menu.menuKornNm,
droppable: true,
data: menu,
};
acc.push(node);
if (menu.children?.length) {
acc.push(...generateMenuTreeData(menu.children));
}
return acc;
}, []);
};
export const generateNewMenuTreeData = (
treeData: NodeModel<TreeMenu>[],
options: DropOptions<TreeMenu>,
): TreeNode | null => {
const { dragSourceId, dragSource, dropTargetId, dropTarget } = options;
if (!dragSource?.data) return null;
const siblings = treeData.filter((node) => node.parent === dropTargetId);
const dragSourceIndex = siblings.findIndex(
(node) => node.id === dragSourceId,
);
const prevSibling = siblings[dragSourceIndex - 1];
const newSortSeq =
dragSourceIndex === 0 ? 1 : (prevSibling?.data?.sortSeq ?? 0) + 1;
const childNodes = treeData.filter(
(node) => node.parent === dragSourceId && node.data,
);
const baseNode = (node: NodeModel<TreeMenu>): TreeNode => {
if (!node.data) return {};
return {
menuCd: node.data?.menuCd,
name: node.data?.menuKornNm,
icon: node.data?.iconNm,
};
};
const treeNode: TreeNode = {
...baseNode(dragSource),
parentId: dropTargetId === 0 ? null : (dropTargetId as number),
sortSeq: newSortSeq,
levelNo: (dropTarget?.data?.levelNo ?? 0) + 1,
};
const result: TreeNode = {
...treeNode,
children: childNodes.map((child) => ({
...baseNode(child),
parentId: child.data?.parentId === 0 ? null : (dropTargetId as number),
sortSeq: child.data?.sortSeq,
levelNo: (treeNode?.levelNo ?? 0) + 1,
})),
};
return result;
};
export const handleSelectNode =
(callback: (node: NodeModel<TreeMenu>) => void) =>
(node: NodeModel<TreeMenu>) => {
callback(node);
};
export const handleDropNode =
(callback: (newTree: TreeNode) => void) =>
(newTree: NodeModel<TreeMenu>[], options: DropOptions<TreeMenu>) => {
const newTreeData = generateNewMenuTreeData(newTree, options);
if (!newTreeData) return;
callback(newTreeData);
};
export const handleNodeTextChange =
(treeData: NodeModel<TreeMenu>[]) =>
(callback: (newTree: NodeModel<TreeMenu>[]) => void) =>
(id: NodeModel["id"], value: string) => {
const newTree = treeData.map((node) => {
if (node.id === id) {
return {
...node,
text: value,
};
}
return node;
});
callback(newTree);
};
export const generateUserOrganizationTreeData = (
organizations: TreeUserOrganization[],
): NodeModel<TreeUserOrganization | TreeUser>[] => {
return organizations.reduce<NodeModel<TreeUserOrganization | TreeUser>[]>(
(acc, org) => {
const orgNode: NodeModel<TreeUserOrganization> = {
id: org.ognzSn,
parent: org.upOgnzSn ?? 0,
text: org.ognzNm,
data: org,
droppable: false,
};
acc.push(orgNode);
if (org.children?.length) {
acc.push(...generateUserOrganizationTreeData(org.children));
}
if (org.users?.length) {
acc.push(
...org.users.map((user) => ({
id: user.userUnqId,
parent: org.ognzSn,
text: user.userNm,
data: user,
droppable: false,
})),
);
}
return acc;
},
[],
);
};
export const filterUserOrganizationTreeDataByKeyword = (
treeData: NodeModel<TreeUserOrganization | TreeUser>[],
keyword: string,
): NodeModel<TreeUserOrganization | TreeUser>[] => {
return Array.from(
treeData.reduce((matchedNodes, node) => {
if (!node.text.includes(keyword)) return matchedNodes;
matchedNodes.add(node);
let parentId = node.parent;
while (parentId) {
const parentNode = treeData.find((n) => n.id === parentId);
if (!parentNode) break;
matchedNodes.add(parentNode);
parentId = parentNode.parent;
}
return matchedNodes;
}, new Set<NodeModel<TreeUserOrganization | TreeUser>>()),
);
};
@@ -1,40 +0,0 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import type { BoardPopup } from "@/entities/board";
export const columns: ColumnDef<BoardPopup>[] = [
{
id: "no",
header: "NO",
cell: ({ row }) => row.index + 1,
},
{
accessorKey: "bbsNm",
header: "게시판명",
},
{
accessorKey: "bbsTypeNm",
header: "게시판유형",
},
{
accessorKey: "regDt",
header: "최초생성일",
},
{
accessorKey: "pstCnt",
header: "게시물건수",
},
{
id: "select",
header: "선택",
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={row.getToggleSelectedHandler()}
className="mx-4 flex justify-center"
/>
);
},
},
];
@@ -1,54 +0,0 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import type { Contents } from "@/entities/content";
export const columns: ColumnDef<Contents>[] = [
{
id: "no",
header: "NO",
cell: ({ row }) => row.index + 1,
},
{
id: "contsTtl",
header: "콘텐츠명",
cell: ({ row }) => row.original.contsTtl,
},
{
accessorKey: "rgTrNm",
header: "승인자",
},
{
accessorKey: "aprvDt",
header: "승인일자",
cell: ({ row }) => {
const date = dayjs(row.original.aprvDt);
return date.format("YYYY-MM-DD");
},
},
{
accessorKey: "mdfrNm",
header: "최종수정자",
},
{
accessorKey: "mdfcnDt",
header: "최종수정일",
cell: ({ row }) => {
const date = dayjs(row.original.mdfcnDt);
return date.format("YYYY-MM-DD");
},
},
{
id: "select",
header: "선택",
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={row.getToggleSelectedHandler()}
className="mx-4 flex justify-center"
/>
);
},
},
];
@@ -1,86 +0,0 @@
import { z } from "zod";
export const MENU_TYPE = {
EMPTY: "EMPTY",
CONTENTS: "CONTENTS",
BOARD: "BOARD",
INSIDE: "INSIDE",
OUTSIDE: "OUTSIDE",
BOTTOM: "BOTTOM",
} as const;
export type MenuType = (typeof MENU_TYPE)[keyof typeof MENU_TYPE];
export interface TreeNode {
menuCd?: number;
name?: string;
sortSeq?: number;
parentId?: number | null;
levelNo?: number;
icon?: string;
children?: TreeNode[];
}
export interface TreeMenu extends Omit<TreeNode, "name" | "icon" | "children"> {
menuKornNm: string;
menuEngNm?: string;
iconNm?: string;
children?: TreeMenu[];
urlPath?: string;
menuLnkgTypeCd: string;
menuLnkgSn?: number;
qrcd?: string;
useYn: string;
otptYn: string;
requiredUrlPath?: boolean;
}
export const menuFormSchema = z.object({
siteId: z.string(),
menuKornNm: z.string(),
menuSubKornNm: z.string().nullish(),
menuEngNm: z.string().nullish(),
menuSubEngNm: z.string().nullish(),
levelNo: z.number(),
urlPath: z.string().nullish(),
iconNm: z.string().nullish(),
menuLnkgTypeCd: z.enum([
MENU_TYPE.EMPTY,
MENU_TYPE.CONTENTS,
MENU_TYPE.BOARD,
MENU_TYPE.INSIDE,
MENU_TYPE.OUTSIDE,
MENU_TYPE.BOTTOM,
]),
menuLnkgSn: z.number().nullish(),
otptYn: z.string().default("Y"),
prntMenuCd: z.number().nullish(),
sortSeq: z.number(),
evlUseYn: z.string().default("Y"),
qrcd: z.string().nullish(),
useYn: z.string().default("Y"),
newWndwYn: z.string().nullish(),
menuLnkgSnNm: z.string().nullish(),
menuPicUserUnqId: z.string().nullish(),
menuPicUserUnqIdNm: z.string().nullish(),
menuPicDsgnYmd: z.string().nullish(),
});
export type MenuFormData = z.infer<typeof menuFormSchema>;
export type Menu = Omit<MenuFormData, "iconNm"> & {
menuCd: number;
iconName?: string;
};
export interface MenuService {
getMenuInfo: (menuCd: number) => Promise<Menu>;
createMenu: (data: MenuFormData) => Promise<void>;
updateMenu: (
menuCd: number,
data: Omit<MenuFormData, "siteId">,
) => Promise<void>;
deleteMenu: (menuCd: number) => Promise<void>;
getTreeMenuList: (siteId: string) => Promise<TreeMenu[]>;
updateTreeMenu: (siteId: string, data: TreeNode[]) => Promise<void>;
updateMenuName: (menuCd: number, name: string) => Promise<void>;
}
@@ -1,124 +0,0 @@
import { type UseFormReturn } from "react-hook-form";
import {
Input,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { FormItem, FormControl, FormField } from "@/shared/ui/form";
import { MENU_TYPE, type MenuFormData } from "../model/menu-interface";
interface BaseMenuFormProps {
form: UseFormReturn<MenuFormData>;
menuCd?: number;
}
function BaseMenuForm({ form, menuCd }: BaseMenuFormProps) {
return (
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="85%" />
</colgroup>
<TableBody>
{menuCd && (
<TableRow>
<TableHead>메뉴ID</TableHead>
<TableCell className="border">
<div className="h-[3.2rem] w-[21.5rem] cursor-not-allowed rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{menuCd}
</div>
</TableCell>
</TableRow>
)}
<TableRow>
<TableHead>메뉴명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="menuKornNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="메뉴명을 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>메뉴 영문명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="menuEngNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="메뉴 영문명을 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>메뉴 유형</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="menuLnkgTypeCd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
{Object.entries(MENU_TYPE).map(([key, value]) => (
<div
key={key}
className="flex items-center space-x-2"
>
<RadioGroupItem value={value} id={key} />
<Label htmlFor={key}>
{key === "EMPTY" && "빈메뉴"}
{key === "CONTENTS" && "콘텐츠"}
{key === "BOARD" && "게시판"}
{key === "INSIDE" && "내부링크"}
{key === "OUTSIDE" && "외부링크"}
{key === "BOTTOM" && "하단메뉴"}
</Label>
</div>
))}
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
export { BaseMenuForm };
@@ -1,95 +0,0 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type RowSelectionState,
} from "@tanstack/react-table";
import {
Button,
DataTable,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
Input,
} from "@common/components/ui";
import { ENDPOINT } from "@/shared/config";
import { BoardApi, type BoardPopup } from "@/entities/board";
import { columns } from "../model/board-table-columns";
type BoardSearchDialogProps = {
handleBoardSelect: (board: BoardPopup) => void;
} & React.ComponentProps<typeof Dialog>;
function BoardSearchDialog({
handleBoardSelect,
...props
}: BoardSearchDialogProps) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [bbsNm, setBbsNm] = useState("");
const { data: boardList, refetch } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.BBS],
queryFn: () => BoardApi.getBoardPopupList({ bbsNm }),
enabled: false,
});
const table = useReactTable({
data: boardList?.content ?? [],
columns,
state: {
rowSelection,
},
enableMultiRowSelection: false,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
});
useEffect(() => {
const boardData = table.getSelectedRowModel().rows[0]
?.original as BoardPopup;
if (!boardData) return;
handleBoardSelect(boardData);
}, [table.getSelectedRowModel().rows]);
useEffect(() => {
refetch();
}, []);
return (
<Dialog {...props}>
<DialogTrigger asChild>
<Button className="gap-3">
<i className="diveicon di-search" />
조회
</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>게시판 조회</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="card card-border !m-0 flex w-full items-center gap-2">
<Input
placeholder="검색어를 입력하세요."
className="!text-[1.3rem]"
value={bbsNm}
onChange={(e) => setBbsNm(e.target.value)}
/>
<Button onClick={() => refetch()}>
<i className="diveicon di-search" />
조회
</Button>
</div>
<div className="h-[40rem] w-full overflow-y-auto">
<DataTable table={table} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
export { BoardSearchDialog };
@@ -1,95 +0,0 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type RowSelectionState,
} from "@tanstack/react-table";
import {
Button,
DataTable,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
Input,
} from "@common/components/ui";
import { ENDPOINT } from "@/shared/config";
import { ContentsApi, type Contents } from "@/entities/content";
import { columns } from "../model/contents-table-colums";
type ContentSearchDialogProps = {
handleContentSelect: (content: Contents) => void;
} & React.ComponentProps<typeof Dialog>;
function ContentSearchDialog({
handleContentSelect,
...props
}: ContentSearchDialogProps) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [contsTtl, setContsTtl] = useState("");
const { data: contentList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS],
queryFn: () => ContentsApi.getContentsList({ contsTtl }),
enabled: false,
});
const table = useReactTable({
data: contentList?.content ?? [],
columns,
state: {
rowSelection,
},
enableMultiRowSelection: false,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
});
useEffect(() => {
const contentData = table.getSelectedRowModel().rows[0]
?.original as Contents;
if (!contentData) return;
handleContentSelect(contentData);
}, [table.getSelectedRowModel().rows]);
useEffect(() => {
refetch();
}, []);
return (
<Dialog {...props}>
<DialogTrigger asChild>
<Button className="gap-3">
<i className="diveicon di-search" />
조회
</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>콘텐츠 조회</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="card card-border !m-0 flex w-full items-center gap-2">
<Input
placeholder="검색어를 입력하세요."
className="!text-[1.3rem]"
value={contsTtl}
onChange={(e) => setContsTtl(e.target.value)}
/>
<Button onClick={() => refetch()}>
<i className="diveicon di-search" />
조회
</Button>
</div>
<div className="h-[40rem] w-full overflow-y-auto">
<DataTable table={table} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
export { ContentSearchDialog };
@@ -1,44 +0,0 @@
import type { ComponentProps } from "react";
import { Button, Input } from "@common/components/ui";
type EditableNodeProps = {
depth?: number;
onSave: () => void;
onCancel: () => void;
} & ComponentProps<typeof Input>;
function EditableNode({
depth,
onSave,
onCancel,
...props
}: EditableNodeProps) {
return (
<div
className="flex items-center gap-2 rounded-sm bg-accent p-2"
style={{ marginInlineStart: !depth ? 0 : depth * 5 + 5 }}
>
<Input {...props} className="h-[2.5rem] !text-[1.3rem] text-foreground" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8 !bg-transparent"
onClick={onSave}
>
<i className="diveicon di-check text-2xl" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 !bg-transparent"
onClick={onCancel}
>
<i className="diveicon di-x" />
</Button>
</div>
</div>
);
}
export { EditableNode };
@@ -1,94 +0,0 @@
import type { UseFormReturn } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button } from "@common/components";
import { Form } from "@/shared/ui/form";
import { ManagementAuthorityDialog } from "@/entities/menu-authority";
import type { MenuFormData } from "../model/menu-interface";
import { BaseMenuForm } from "./base-menu-form";
import { TypeMenuForm } from "./type-menu-form";
interface MenuFormProps {
form: UseFormReturn<MenuFormData>;
menuCd?: number;
handleCreate: (data: MenuFormData) => void;
handleUpdate: ({
menuCd,
data,
}: {
menuCd: number;
data: Omit<MenuFormData, "siteId">;
}) => void;
handleDelete?: (menuCd: number) => void;
}
function MenuForm({
form,
menuCd,
handleCreate,
handleUpdate,
handleDelete,
}: MenuFormProps) {
const isModifyMode = !!menuCd;
const { confirm } = useConfirm();
const onSubmit = async (data: MenuFormData) => {
if (isModifyMode) {
await onUpdate(data);
} else {
await onCreate(data);
}
};
const onCreate = async (data: MenuFormData) => {
if (await confirm("저장하시겠습니까?")) {
handleCreate(data);
}
};
const onUpdate = async (data: MenuFormData) => {
if (await confirm("수정하시겠습니까?")) {
handleUpdate({ menuCd: menuCd!, data });
}
};
const onDelete = async () => {
if (await confirm("삭제하시겠습니까?")) {
handleDelete?.(menuCd!);
}
};
const menuType = form.watch("menuLnkgTypeCd");
const isSubmitDisabled = !form.watch("siteId");
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{isModifyMode ? "메뉴 수정" : "메뉴 등록"}
</h3>
<ManagementAuthorityDialog menuCd={menuCd} />
</div>
<div className="card">
<BaseMenuForm form={form} menuCd={menuCd} />
<TypeMenuForm form={form} menuType={menuType} />
</div>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button size="lg" type="submit" disabled={isSubmitDisabled}>
저장
</Button>
{isModifyMode && (
<Button size="lg" color="red" type="button" onClick={onDelete}>
삭제
</Button>
)}
</div>
</form>
</Form>
);
}
export { MenuForm };
@@ -1,115 +0,0 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { NodeModel, TreeMethods } from "@minoru/react-dnd-treeview";
import { Button } from "@common/components/ui";
import { cn } from "@common/components/lib";
import { ENDPOINT } from "@/shared/config";
import { MenuApi } from "../api/menu-service";
import type { TreeMenu } from "../model/menu-interface";
import { EditableNode } from "./editable-node";
type MenuNodeProps = {
node: NodeModel<TreeMenu>;
depth: number;
isOpen: boolean;
isSelected: boolean;
isAdditionalParent: boolean;
treeMethods: TreeMethods | null;
onToggle: (id: NodeModel["id"]) => void;
onSelect: (node: NodeModel<TreeMenu>) => void;
};
function MenuNode({
node,
depth,
isOpen,
isSelected,
isAdditionalParent,
treeMethods,
onToggle,
onSelect,
}: MenuNodeProps) {
const { id, text, data } = node;
const indent = depth * 10 + 5;
const queryClient = useQueryClient();
const { mutateAsync: updateMenuName } = useMutation({
mutationFn: (nodeName: string) => {
const encodedName = encodeURIComponent(nodeName);
return MenuApi.updateMenuName(id as number, encodedName);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS],
});
},
});
const [editMode, setEditMode] = useState(false);
const [nodeName, setNodeName] = useState(text);
const handleToggle = () => onToggle(id);
const handleSelect = () => onSelect(node);
const handleSave = async () => {
await updateMenuName(nodeName);
setEditMode(false);
};
const handleCancel = () => {
setEditMode(false);
setNodeName(text);
};
useEffect(() => {
if (isAdditionalParent) treeMethods?.open(id);
}, [isAdditionalParent]);
return (
<div
style={{ paddingInlineStart: indent }}
className={cn(
"group flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent",
isSelected && "!bg-[rgba(32,148,250,0.2)]",
)}
>
{editMode ? (
<EditableNode
value={nodeName}
onChange={(e) => setNodeName(e.target.value)}
onSave={handleSave}
onCancel={handleCancel}
/>
) : (
<>
<div className="flex items-center" onClick={handleSelect}>
{data?.children?.length && (
<i
className={cn(
"diveicon di-arrow-forward mr-2 text-base text-label",
isOpen && "rotate-90",
)}
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
/>
)}
<span className="text-[1.4rem] font-medium text-label">{text}</span>
</div>
<Button
variant="ghost"
size="icon"
className="invisible !bg-transparent group-hover:visible"
onClick={() => setEditMode(true)}
>
<i className="diveicon di-pencil" />
</Button>
</>
)}
</div>
);
}
export { MenuNode };
@@ -1,256 +0,0 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import type { NodeModel } from "@minoru/react-dnd-treeview";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components/ui";
import { SiteApi } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import type { TreeMenu } from "../model/menu-interface";
import { MenuApi } from "../api/menu-service";
import {
MENU_TYPE,
menuFormSchema,
type MenuFormData,
} from "../model/menu-interface";
import { MenuTree } from "./menu-tree";
import { MenuForm } from "./menu-form";
import { generateMenuTreeData } from "../lib/utils";
function MenuPage() {
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const [siteId, setSiteId] = useState("");
const [selectedMenu, setSelectedMenu] = useState<TreeMenu | null>(null);
const [additionalNode, setAdditionalNode] =
useState<NodeModel<TreeMenu> | null>(null);
const { data: menuInfo } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, selectedMenu?.menuCd],
queryFn: () => MenuApi.getMenuInfo(selectedMenu?.menuCd ?? 0),
enabled: !!selectedMenu?.menuCd,
});
const { data: menuTreeData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
queryFn: () => MenuApi.getTreeMenuList(siteId),
select: (data) => {
const treeData = generateMenuTreeData(data);
const defaultSortSeq =
treeData.filter((node) => node.data?.levelNo === 1).length + 1;
form.setValue("sortSeq", defaultSortSeq);
return treeData;
},
enabled: !!siteId,
});
const { data: options } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
queryFn: () => SiteApi.getSiteList(),
select: (data) =>
data.content.map((site) => ({
label: site.siteNm,
value: site.siteId,
})),
});
const { mutateAsync: createMenu } = useMutation({
mutationFn: (data: MenuFormData) => MenuApi.createMenu(data),
onSuccess: () => {
alert("저장되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
});
},
});
const { mutate: updateMenu } = useMutation({
mutationFn: ({
menuCd,
data,
}: {
menuCd: number;
data: Omit<MenuFormData, "siteId">;
}) => MenuApi.updateMenu(menuCd, data),
onSuccess: () => {
alert("수정되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
});
},
});
const { mutate: deleteMenu } = useMutation({
mutationFn: (menuCd: number) => MenuApi.deleteMenu(menuCd),
onSuccess: () => {
alert("삭제되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
});
},
});
const form = useForm<MenuFormData>({
resolver: zodResolver(menuFormSchema),
defaultValues: {
menuLnkgTypeCd: MENU_TYPE.EMPTY,
},
values: {
...menuInfo,
siteId,
levelNo: menuInfo?.levelNo ?? 1,
sortSeq: menuInfo?.sortSeq ?? 1,
menuKornNm: menuInfo?.menuKornNm ?? "",
menuLnkgTypeCd: menuInfo?.menuLnkgTypeCd ?? MENU_TYPE.EMPTY,
iconNm: menuInfo?.iconName,
otptYn: menuInfo?.otptYn ?? "Y",
evlUseYn: menuInfo?.evlUseYn ?? "Y",
useYn: menuInfo?.useYn ?? "Y",
},
});
const additionalNodeForm = useForm<MenuFormData>({
resolver: zodResolver(menuFormSchema),
defaultValues: {
menuLnkgTypeCd: MENU_TYPE.EMPTY,
levelNo: 1,
sortSeq: 1,
otptYn: "Y",
evlUseYn: "Y",
useYn: "Y",
},
});
const handleAdditionalNodeSave = async () => {
const prntMenuCd = !additionalNode?.parent
? null
: (additionalNode?.parent as number);
await createMenu({
...additionalNodeForm.getValues(),
prntMenuCd,
});
resetAdditionalNode();
};
const resetAdditionalNode = () => {
additionalNodeForm.setValue("menuKornNm", "");
setAdditionalNode(null);
};
const handleSiteIdChange = (value: string) => {
form.setValue("siteId", value);
additionalNodeForm.setValue("siteId", value);
setSiteId(value);
setSelectedMenu(null);
};
const handleNodeSelect = (node: NodeModel<TreeMenu>) => {
if (!node.data) return;
if (node.data.menuCd === selectedMenu?.menuCd) {
setSelectedMenu(null);
additionalNodeForm.setValue("levelNo", 1);
return;
}
setSelectedMenu(node.data);
additionalNodeForm.setValue("levelNo", (node.data.levelNo ?? 0) + 1);
};
const handleNodeDelete = async () => {
if (await confirm("삭제하시겠습니까?")) {
deleteMenu(selectedMenu!.menuCd!);
}
};
const handleAddMenu = () => {
if (additionalNode) return;
setAdditionalNode({
id: -1,
parent: selectedMenu?.menuCd ?? 0,
text: "",
});
};
const tree = additionalNode
? [...(additionalNode ? [additionalNode] : []), ...(menuTreeData ?? [])]
: (menuTreeData ?? []);
return (
<div className="card !mb-0 flex !min-h-[calc(100vh-16.8rem)] gap-2">
<div className="card card-border w-[24rem]">
<Select value={siteId} onValueChange={handleSiteIdChange}>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{siteId && (
<>
<div className="my-4 flex gap-2">
<Button
size="sm"
color="primary"
className="flex-1"
onClick={handleAddMenu}
>
메뉴 추가
</Button>
<Button
size="sm"
color="red"
className="flex-1"
onClick={handleNodeDelete}
disabled={!selectedMenu}
>
메뉴 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<MenuTree
tree={tree}
siteId={siteId}
selectedMenu={selectedMenu}
additionalNodeForm={additionalNodeForm}
onSelectNode={handleNodeSelect}
onAdditionalNodeSave={handleAdditionalNodeSave}
onAdditionalNodeCancel={resetAdditionalNode}
/>
</div>
</>
)}
</div>
<div className="card card-border flex-1">
<MenuForm
form={form}
menuCd={selectedMenu?.menuCd}
handleCreate={createMenu}
handleUpdate={updateMenu}
handleDelete={deleteMenu}
/>
</div>
</div>
);
}
export { MenuPage };
@@ -1,151 +0,0 @@
"use client";
import { useRef } from "react";
import { DndProvider } from "react-dnd";
import type { UseFormReturn } from "react-hook-form";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Tree,
MultiBackend,
getBackendOptions,
type NodeModel,
type TreeMethods,
} from "@minoru/react-dnd-treeview";
import { Button } from "@common/components/ui";
import { ENDPOINT } from "@/shared/config";
import { MenuApi } from "../api/menu-service";
import type { MenuFormData, TreeMenu, TreeNode } from "../model/menu-interface";
import { handleDropNode, handleSelectNode } from "../lib/utils";
import { MenuNode } from "./menu-node";
import { EditableNode } from "./editable-node";
type MenuTreeProps = {
tree: NodeModel<TreeMenu>[];
siteId: string;
selectedMenu: TreeMenu | null;
additionalNodeForm: UseFormReturn<MenuFormData>;
onSelectNode: (node: NodeModel<TreeMenu>) => void;
onAdditionalNodeSave: () => void;
onAdditionalNodeCancel: () => void;
};
function MenuTree({
tree,
siteId,
selectedMenu,
additionalNodeForm,
onSelectNode,
onAdditionalNodeSave,
onAdditionalNodeCancel,
}: MenuTreeProps) {
const queryClient = useQueryClient();
const treeMethods = useRef<TreeMethods>(null);
const { mutate: updateTreeMenu } = useMutation({
mutationFn: (newTree: TreeNode) =>
MenuApi.updateTreeMenu(siteId, [newTree]),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS],
});
},
});
const additionalNode = tree.find((n) => n.id === -1);
const isAdditionalParent = (node: NodeModel<TreeMenu>): boolean => {
if (!additionalNode) return false;
return additionalNode?.parent === node.id;
};
return (
<>
<div className="max-h-[calc(100vh-33.5rem)] overflow-auto">
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={tree}
rootId={0}
ref={treeMethods}
render={(
node: NodeModel<TreeMenu>,
{ depth, isOpen, onToggle },
) => {
if (node.id === -1) {
return (
<EditableNode
value={additionalNodeForm.watch("menuKornNm") ?? ""}
onChange={(e) =>
additionalNodeForm.setValue("menuKornNm", e.target.value)
}
depth={depth}
onSave={onAdditionalNodeSave}
onCancel={onAdditionalNodeCancel}
/>
);
}
return (
<MenuNode
node={node}
depth={depth}
treeMethods={treeMethods.current}
isOpen={isOpen}
isSelected={selectedMenu?.menuCd === node.id}
isAdditionalParent={isAdditionalParent(node)}
onToggle={onToggle}
onSelect={handleSelectNode(onSelectNode)}
/>
);
}}
onDrop={handleDropNode(updateTreeMenu)}
classes={{
root: "p-4",
container: "flex flex-col",
dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm",
placeholder: "relative",
}}
sort={false}
dropTargetOffset={5}
insertDroppableFirst={false}
canDrop={(_, { dragSource, dropTargetId }) => {
if (dragSource?.parent === dropTargetId) {
return true;
}
}}
placeholderRender={(_, { depth }) => {
return (
<div
className="absolute right-0 top-0 h-[0.2rem] bg-primary"
style={{ left: depth * 10 + 5 }}
/>
);
}}
/>
</DndProvider>
</div>
<div className="flex gap-2">
<Button
color="lightGray"
className="flex-1 items-center px-0"
onClick={() => {
treeMethods.current?.openAll();
}}
>
<i className="diveicon di-arrows-angle-expand" />
전체펼침
</Button>
<Button
color="lightGray"
className="flex-1 items-center px-0"
onClick={() => {
treeMethods.current?.closeAll();
}}
>
<i className="diveicon di-arrows-angle-contract" />
전체접기
</Button>
</div>
</>
);
}
export { MenuTree };
@@ -1,187 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { DndProvider } from "react-dnd";
import {
Tree,
MultiBackend,
getBackendOptions,
type NodeModel,
} from "@minoru/react-dnd-treeview";
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
} from "@common/components/ui";
import { cn } from "@common/components/lib";
import {
UserOrganizationApi,
type TreeUserOrganization,
type TreeUser,
} from "@/entities/user-organization";
import { ENDPOINT } from "@/shared/config";
import {
filterUserOrganizationTreeDataByKeyword,
generateUserOrganizationTreeData,
} from "../lib/utils";
type TreeModel = TreeUserOrganization | TreeUser;
interface OrganizationSearchDialogProps {
handleUserSelect: (user: TreeUser) => void;
}
function OrganizationSearchDialog({
handleUserSelect,
}: OrganizationSearchDialogProps) {
const [keyword, setKeyword] = useState("");
const [tree, setTree] = useState<NodeModel<TreeModel>[]>([]);
const [selectedNode, setSelectedNode] = useState<NodeModel<TreeModel> | null>(
null,
);
const { data: treeData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.USER_ORGANIZATION],
queryFn: () => UserOrganizationApi.getUserOrganizationTreeList(),
select: (data) => generateUserOrganizationTreeData(data),
});
const handleSelectNode = (node: NodeModel<TreeModel>) => {
setSelectedNode(node);
handleUserSelect(node.data as TreeUser);
};
useEffect(() => {
setTree(treeData ?? []);
}, [treeData]);
return (
<Dialog>
<DialogTrigger asChild>
<Button className="gap-3">
<i className="diveicon di-search" />
조회
</Button>
</DialogTrigger>
<DialogContent className="max-w-[30rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>담당자 조회</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="card card-border !m-0 flex w-full items-center gap-2">
<Input
className="h-[2.5rem] !text-[1.3rem]"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Button
size="sm"
onClick={() =>
setTree(filterUserOrganizationTreeDataByKeyword(tree, keyword))
}
>
<i className="diveicon di-search text-base" />
조회
</Button>
</div>
<div className="h-[36rem] overflow-auto">
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={tree}
rootId={0}
render={(
node: NodeModel<TreeModel>,
{ depth, isOpen, onToggle },
) => (
<TreeNode
node={node}
depth={depth}
isOpen={isOpen}
selectedNode={selectedNode}
onToggle={onToggle}
onSelect={handleSelectNode}
/>
)}
classes={{
container: "flex flex-col",
}}
sort={false}
onDrop={() => {}}
/>
</DndProvider>
</div>
</div>
</DialogContent>
</Dialog>
);
}
interface TreeNodeProps {
node: NodeModel<TreeModel>;
depth: number;
isOpen: boolean;
selectedNode: NodeModel<TreeModel> | null;
onToggle: (id: NodeModel["id"]) => void;
onSelect: (node: NodeModel<TreeModel>) => void;
}
const ORGANIZATION_PREFIX = "OGNZ";
function TreeNode({
node,
depth,
isOpen,
selectedNode,
onToggle,
onSelect,
}: TreeNodeProps) {
const { id, text, data } = node;
const indent = depth * 15 + 5;
const handleToggle = () => onToggle(id);
const handleSelect = () => {
if (id.toString().startsWith(ORGANIZATION_PREFIX)) return;
onSelect(node);
};
const isSelected = selectedNode?.id === id;
return (
<div
style={{ paddingInlineStart: indent }}
className="group flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent"
onClick={handleSelect}
>
<div className="flex w-full items-center justify-between">
<div>
{data && ("children" in data || "users" in data) && (
<Button
size="icon"
className="h-6 w-6 !bg-transparent"
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
>
<i
className={cn(
"diveicon di-arrow-forward mr-2 text-base text-label",
isOpen && "rotate-90",
)}
/>
</Button>
)}
<span className="text-[1.4rem] font-medium text-label">{text}</span>
</div>
{isSelected && <i className="diveicon di-check text-xl text-primary" />}
</div>
</div>
);
}
export { OrganizationSearchDialog };
@@ -1,368 +0,0 @@
import dayjs from "dayjs";
import { type UseFormReturn } from "react-hook-form";
import {
DatePicker,
IconFinder,
Input,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import type { BoardPopup } from "@/entities/board";
import type { Contents } from "@/entities/content";
import type { TreeUser } from "@/entities/user-organization";
import { FormItem, FormControl, FormField } from "@/shared/ui/form";
import type { MenuFormData, MenuType } from "../model/menu-interface";
import { OrganizationSearchDialog } from "./organization-search-dialog";
import { ContentSearchDialog } from "./content-search-dialog";
import { BoardSearchDialog } from "./board-search-dialog";
type TypeMenuFormProps = {
form: UseFormReturn<MenuFormData>;
menuType: MenuType;
};
function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
const handleIconClick = (icon: string) => {
form.setValue("iconNm", icon);
};
const handleContentSelect = (content: Contents) => {
form.setValue("menuLnkgSn", content.contsSn);
form.setValue("menuLnkgSnNm", content.contsTtl);
};
const handleUserSelect = (user: TreeUser) => {
form.setValue("menuPicUserUnqId", user.userUnqId);
form.setValue("menuPicUserUnqIdNm", user.userNm);
};
const handleBoardSelect = (board: BoardPopup) => {
form.setValue("menuLnkgSn", board.bbsId);
form.setValue("menuLnkgSnNm", board.bbsNm);
};
const handleDateSelect = (date: Date | undefined) => {
if (!date) return;
form.setValue("menuPicDsgnYmd", dayjs(date).format("YYYYMMDD"));
};
return (
<Table variant="secondary" className="-mt-[0.1rem]">
<colgroup>
<col width="15%" />
<col width="85%" />
</colgroup>
<TableBody>
{menuType === "CONTENTS" && (
<TableRow>
<TableHead>콘텐츠</TableHead>
<TableCell className="border">
<div className="flex items-center gap-2">
<div className="h-[3.2rem] w-1/2 cursor-not-allowed overflow-hidden whitespace-nowrap break-keep rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{!form.watch("menuLnkgSnNm") &&
"메뉴에 표시될 콘텐츠를 조회 후 선택하십시오."}
{form.watch("menuLnkgSnNm")}
</div>
<ContentSearchDialog
handleContentSelect={handleContentSelect}
/>
</div>
</TableCell>
</TableRow>
)}
{menuType === "BOARD" && (
<TableRow>
<TableHead>게시판</TableHead>
<TableCell className="border">
<div className="flex items-center gap-2">
<div className="h-[3.2rem] w-1/2 cursor-not-allowed overflow-hidden whitespace-nowrap break-keep rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{!form.watch("menuLnkgSnNm") &&
"메뉴에 표시될 게시판을 조회 후 선택하십시오."}
{form.watch("menuLnkgSnNm")}
</div>
<BoardSearchDialog handleBoardSelect={handleBoardSelect} />
</div>
</TableCell>
</TableRow>
)}
{(menuType === "INSIDE" || menuType === "OUTSIDE") && (
<TableRow>
<TableHead>연결 URL</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="urlPath"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder={
menuType === "INSIDE"
? "메뉴에 연결된 페이지의 경로를 입력하십시오."
: "'http://' 또는 'https://'를 포함하여 입력하십시오."
}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
{(menuType === "CONTENTS" ||
menuType === "BOARD" ||
menuType === "INSIDE" ||
menuType === "OUTSIDE") && (
<TableRow>
<TableHead>연결 형태</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="newWndwYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="newWndwY" />
<Label htmlFor="newWndwY">새창</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="newWndwN" />
<Label htmlFor="newWndwN">현재창</Label>
</div>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
{(menuType === "CONTENTS" || menuType === "BOARD") && (
<TableRow>
<TableHead>메뉴 평가 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="evlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="evlUseY" />
<Label htmlFor="evlUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="evlUseN" />
<Label htmlFor="evlUseN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
{/* 메뉴 유형 공통 폼 S */}
<TableRow>
<TableHead>메뉴 서브명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="menuSubKornNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트맵에서 다른 이름으로 사용할 경우 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>메뉴 아이콘</TableHead>
<TableCell className="border">
<div className="flex gap-2">
<div className="h-[3.2rem] w-[21.5rem] cursor-not-allowed rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{!form.watch("iconNm") && "아이콘을 조회 후 선택하십시오."}
{form.watch("iconNm")}
</div>
<IconFinder handleClick={handleIconClick} />
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>출력 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="otptYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="otptY" />
<Label htmlFor="otptY">출력</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="otptN" />
<Label htmlFor="otptN">출력안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 메뉴 유형 공통 폼 E */}
{(menuType === "CONTENTS" || menuType === "BOARD") && (
<>
<TableRow>
<TableHead>QR코드</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="qrcd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="qrcdY" />
<Label htmlFor="qrcdY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="qrcdN" />
<Label htmlFor="qrcdN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>담당자</TableHead>
<TableCell className="border">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<div className="h-[3.2rem] w-[21.5rem] cursor-not-allowed rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{!form.watch("menuPicUserUnqIdNm") &&
"메뉴 담당자를 조회 후 선택하십시오."}
{form.watch("menuPicUserUnqIdNm")}
</div>
<OrganizationSearchDialog
handleUserSelect={handleUserSelect}
/>
</div>
<div className="flex items-center gap-2">
<b>지정일:</b>
<FormField
control={form.control}
name="menuPicDsgnYmd"
render={({ field }) => (
<FormItem>
<FormControl>
<DatePicker
mode="single"
selected={
field.value
? dayjs(field.value).toDate()
: undefined
}
onSelect={(date) => {
field.onChange(date);
handleDateSelect(date);
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</TableCell>
</TableRow>
</>
)}
</TableBody>
</Table>
);
}
export { TypeMenuForm };
@@ -1,53 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
AuthorizationsService,
AuthorizationsParams,
AuthorizationsList,
AuthorizationFormData,
WithRoleAuthorizations,
} from "../model/authorizations-interface";
export class AuthorizationsApiService implements AuthorizationsService {
public async getAuthorizationsList(
params: AuthorizationsParams,
): Promise<AuthorizationsList> {
const response = await client.get<AuthorizationsList>(
`${ENDPOINT.USER_SERVICE.AUTHORIZATIONS}`,
{ params },
);
return response.data;
}
public async getAuthorizationInfo(
authorizationNo: string,
): Promise<WithRoleAuthorizations> {
const response = await client.get<WithRoleAuthorizations>(
`${ENDPOINT.USER_SERVICE.AUTHORIZATIONS}/${authorizationNo}`,
);
return response.data;
}
public async createAuthorization(
data: AuthorizationFormData,
): Promise<WithRoleAuthorizations> {
const response = await client.post<WithRoleAuthorizations>(
`${ENDPOINT.USER_SERVICE.AUTHORIZATIONS}`,
data,
);
return response.data;
}
public async updateAuthorization(
authorizationNo: string,
data: AuthorizationFormData,
): Promise<WithRoleAuthorizations> {
const response = await client.put<WithRoleAuthorizations>(
`${ENDPOINT.USER_SERVICE.AUTHORIZATIONS}/${authorizationNo}`,
data,
);
return response.data;
}
}
export const AuthorizationsApi = new AuthorizationsApiService();
@@ -1,43 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
RoleAuthorizationsService,
RoleAuthorizationsParams,
RoleAuthorizationsList,
RoleAuthorizationsData,
RoleAuthorizations,
} from "../model/role-authorizations-interface";
export class RoleAuthorizationsApiService implements RoleAuthorizationsService {
public async getRoleAuthorizationsList(
params: RoleAuthorizationsParams,
): Promise<RoleAuthorizationsList> {
const response = await client.get<RoleAuthorizationsList>(
`${ENDPOINT.USER_SERVICE.ROLE_AUTHORIZATIONS}/api`,
{ params },
);
return response.data;
}
public async createRoleAuthorizations(
data: RoleAuthorizationsData,
): Promise<RoleAuthorizations> {
const response = await client.post<RoleAuthorizations>(
`${ENDPOINT.USER_SERVICE.ROLE_AUTHORIZATIONS}`,
data,
);
return response.data;
}
public async updateRoleAuthorizations(
data: RoleAuthorizationsData,
): Promise<RoleAuthorizations> {
const response = await client.put<RoleAuthorizations>(
`${ENDPOINT.USER_SERVICE.ROLE_AUTHORIZATIONS}`,
data,
);
return response.data;
}
}
export const RoleAuthorizationsApi = new RoleAuthorizationsApiService();
@@ -1,3 +0,0 @@
export { ModulePage } from "./ui/module-page";
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
@@ -1,62 +0,0 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface AuthorizationsParams extends PageableParams {
keywordType?: string;
keyword?: string;
authorizationName?: string;
httpMethodCode?: string;
useYn?: string;
}
export interface Authorizations {
authorizationNo: number;
authorizationName: string;
urlPatternValue: string;
httpMethodCode: string;
sortSeq: number;
useYn: string;
mdfr: string;
mdfcnDt: string;
}
export interface RoleAuthorizations {
roleId: string;
authorizationNo: number;
authorizationName: string;
urlPatternValue: string;
httpMethodCode: string;
sortSeq: number;
createdAt: string;
}
export interface WithRoleAuthorizations extends Authorizations {
roleAuthorizations: RoleAuthorizations[];
}
export const authorizationFormSchema = z.object({
authorizationName: z.string(),
urlPatternValue: z.string(),
httpMethodCode: z.string(),
sortSeq: z.number(),
useYn: z.string(),
});
export type AuthorizationsList = PageableList<Authorizations>;
export type AuthorizationFormData = z.infer<typeof authorizationFormSchema>;
export interface AuthorizationsService {
getAuthorizationsList: (
params: AuthorizationsParams,
) => Promise<AuthorizationsList>;
getAuthorizationInfo: (
authorizationNo: string,
) => Promise<WithRoleAuthorizations>;
createAuthorization: (
data: AuthorizationFormData,
) => Promise<WithRoleAuthorizations>;
updateAuthorization: (
authorizationNo: string,
data: AuthorizationFormData,
) => Promise<WithRoleAuthorizations>;
}
@@ -1,58 +0,0 @@
import dayjs from "dayjs";
import { type ColumnDef } from "@tanstack/react-table";
import type { Authorizations } from "./authorizations-interface";
import { ManagementAuthorityDialog } from "../ui/management-authority-dialog";
export const columns: ColumnDef<Authorizations>[] = [
{
id: "no",
header: "NO",
cell: ({ row }) => row.index + 1,
},
{
accessorKey: "authorizationName",
header: "API명",
},
{
accessorKey: "urlPatternValue",
header: "API URL",
},
{
accessorKey: "httpMethodCode",
header: "요청 메소드",
},
{
accessorKey: "useYn",
header: "사용여부",
},
{
accessorKey: "mdfr",
header: "최종수정자",
},
{
accessorKey: "mdfcnDt",
header: "최종수정일",
cell: ({ row }) => {
const date = dayjs(row.original.mdfcnDt);
return date.format("YYYY-MM-DD");
},
},
{
id: "action",
header: "권한관리",
cell: ({ row }) => {
return (
<div
onClick={(e) => {
e.stopPropagation();
}}
>
<ManagementAuthorityDialog
authorizationNo={row.original.authorizationNo.toString()}
rowData={row.original}
/>
</div>
);
},
},
];
@@ -1,45 +0,0 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface RoleAuthorizationsParams extends PageableParams {
keywordType?: string;
keyword?: string;
authorizationNo?: string;
}
export interface RoleAuthorizations {
roleId: string;
roleName: string;
roleContent: string;
useYn: string;
authorizationNo: number;
authYn: string;
}
export const roleAuthorizationsSchema = z.object({
roleId: z.string(),
authorizationNo: z.number(),
authYn: z.string(),
});
export const roleAuthorizationsFormSchema = z.object({
roleAuthorizations: z.array(roleAuthorizationsSchema),
});
export type RoleAuthorizationsList = PageableList<RoleAuthorizations>;
export type RoleAuthorizationsData = z.infer<typeof roleAuthorizationsSchema>[];
export type RoleAuthorizationsFormData = z.infer<
typeof roleAuthorizationsFormSchema
>;
export interface RoleAuthorizationsService {
getRoleAuthorizationsList: (
params: RoleAuthorizationsParams,
) => Promise<RoleAuthorizationsList>;
createRoleAuthorizations: (
data: RoleAuthorizationsData,
) => Promise<RoleAuthorizations>;
updateRoleAuthorizations: (
data: RoleAuthorizationsData,
) => Promise<RoleAuthorizations>;
}
@@ -1,53 +0,0 @@
import type { ColumnDef } from "@tanstack/react-table";
import type { UseFormReturn } from "react-hook-form";
import { Checkbox } from "@common/components/ui";
import type { RoleAuthorizations } from "./role-authorizations-interface";
import type { RoleAuthorizationsFormData } from "./role-authorizations-interface";
export const createColumns = (
form: UseFormReturn<RoleAuthorizationsFormData>,
): ColumnDef<RoleAuthorizations>[] => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={form
.watch("roleAuthorizations")
.every((role) => role.authYn === "Y")}
onCheckedChange={(checked) => {
table.getRowModel().rows.forEach((row, index) => {
form.setValue(
`roleAuthorizations.${index}.authYn`,
checked ? "Y" : "N",
);
});
}}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={form.watch(`roleAuthorizations.${row.index}.authYn`) === "Y"}
onCheckedChange={(checked) => {
form.setValue(
`roleAuthorizations.${row.index}.authYn`,
checked ? "Y" : "N",
);
}}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
accessorKey: "roleId",
header: "권한ID",
},
{
accessorKey: "roleName",
header: "권한명",
},
{
accessorKey: "roleContent",
header: "권한설명",
},
];
@@ -1,45 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import {
authorizationFormSchema,
type AuthorizationFormData,
} from "../model/authorizations-interface";
import { AuthorizationsApi } from "../api/authorization-service";
import { ModuleForm } from "./module-form";
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createAuthorization } = useMutation({
mutationFn: (data: AuthorizationFormData) =>
AuthorizationsApi.createAuthorization(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<AuthorizationFormData>({
resolver: zodResolver(authorizationFormSchema),
defaultValues: {
sortSeq: 0,
useYn: "Y",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">모듈API 등록</h3>
</div>
<ModuleForm form={form} handleSave={createAuthorization} />
</div>
);
}
export { CreatePage };
@@ -1,148 +0,0 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type RowSelectionState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
DataTable,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components/ui";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import {
roleAuthorizationsFormSchema,
type RoleAuthorizationsFormData,
type RoleAuthorizationsData,
} from "../model/role-authorizations-interface";
import { createColumns } from "../model/role-authorizations-table-columns";
import type { Authorizations } from "../model/authorizations-interface";
import { RoleAuthorizationsApi } from "../api/role-authorization-service";
type ManagementAuthorityDialogProps = {
authorizationNo?: string;
rowData?: Authorizations;
} & React.ComponentProps<typeof Dialog>;
function ManagementAuthorityDialog({
authorizationNo,
rowData,
...props
}: ManagementAuthorityDialogProps) {
const { confirm } = useConfirm();
const { setMessage: alert } = useAlertStore((state) => state);
const [isOpened, setIsOpened] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { data: roleAuthorizationList } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.ROLE_AUTHORIZATIONS, authorizationNo],
queryFn: () =>
RoleAuthorizationsApi.getRoleAuthorizationsList({ authorizationNo }),
enabled: !!authorizationNo && isOpened,
});
const { mutateAsync: saveRoleAuthorization } = useMutation({
mutationFn: (data: RoleAuthorizationsData) =>
RoleAuthorizationsApi.createRoleAuthorizations(data),
onSuccess: () => {
alert("저장되었습니다.");
},
});
const form = useForm<RoleAuthorizationsFormData>({
resolver: zodResolver(roleAuthorizationsFormSchema),
values: {
roleAuthorizations:
roleAuthorizationList?.content.map(
({ roleId, authorizationNo, useYn }) => ({
roleId,
authorizationNo,
authYn: useYn,
}),
) ?? [],
},
});
const table = useReactTable({
data: roleAuthorizationList?.content ?? [],
columns: createColumns(form),
state: {
rowSelection,
},
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
});
const handleSave = async () => {
if (await confirm("저장하시겠습니까?")) {
const data = form.getValues("roleAuthorizations");
await saveRoleAuthorization(data);
}
};
return (
<Dialog {...props}>
<DialogTrigger asChild>
<Button size="sm" onClick={() => setIsOpened(true)}>
관리
</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>모듈API 권한관리</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-8">
<Table variant="line">
<TableHeader>
<TableRow>
<TableHead>API명</TableHead>
<TableHead>요청 메소드</TableHead>
<TableHead>API URL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell align="center">
{rowData?.authorizationName}
</TableCell>
<TableCell align="center">{rowData?.httpMethodCode}</TableCell>
<TableCell align="center">{rowData?.urlPatternValue}</TableCell>
</TableRow>
</TableBody>
</Table>
<DataTable table={table} />
</div>
<DialogFooter className="!justify-center">
<Button size="lg" onClick={handleSave}>
저장
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ManagementAuthorityDialog };
@@ -1,50 +0,0 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ENDPOINT } from "@/shared/config";
import {
authorizationFormSchema,
type AuthorizationFormData,
} from "../model/authorizations-interface";
import { AuthorizationsApi } from "../api/authorization-service";
import { ModuleForm } from "./module-form";
function ModifyPage({ sequential }: { sequential: string }) {
const { setMessage: alert } = useAlertStore((state) => state);
const { data: authorizationInfo } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.AUTHORIZATIONS, sequential],
queryFn: () => AuthorizationsApi.getAuthorizationInfo(sequential),
});
const { mutateAsync: updateAuthorization } = useMutation({
mutationFn: (data: AuthorizationFormData) =>
AuthorizationsApi.updateAuthorization(sequential, data),
onSuccess: () => {
alert("수정되었습니다.");
},
});
const form = useForm<AuthorizationFormData>({
resolver: zodResolver(authorizationFormSchema),
values: authorizationInfo,
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">모듈API 수정</h3>
</div>
<ModuleForm
form={form}
authorizationNo={Number(sequential)}
handleSave={updateAuthorization}
/>
</div>
);
}
export { ModifyPage };
@@ -1,166 +0,0 @@
"use client";
import type { UseFormReturn } from "react-hook-form";
import {
Input,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { FormWrapper } from "@/widgets/form-wrapper";
import { CodeSelect } from "@/entities/code";
import { FormControl, FormField, FormItem } from "@/shared/ui/form";
import type {
AuthorizationFormData,
WithRoleAuthorizations,
} from "../model/authorizations-interface";
type BannerFormProps = {
form: UseFormReturn<AuthorizationFormData>;
authorizationNo?: number;
handleSave: (data: AuthorizationFormData) => Promise<WithRoleAuthorizations>;
handleDelete?: () => Promise<void>;
};
function ModuleForm({ form, authorizationNo, ...props }: BannerFormProps) {
return (
<FormWrapper form={form} isModifyMode={!!authorizationNo} {...props}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="85%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>API명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="authorizationName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="API명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>API URL</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="urlPatternValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="API URL을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>요청 메소드</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="httpMethodCode"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormControl>
<CodeSelect
{...field}
upCd="HTTPMHD"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>정렬순서</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="sortSeq"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="number"
min={0}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (isNaN(Number(value))) return;
field.onChange(Number(value));
}}
className="w-[12rem] !text-[1.3rem]"
placeholder="정렬순서 입력"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</FormWrapper>
);
}
export { ModuleForm };

Some files were not shown because too many files have changed in this diff Show More