This commit is contained in:
2025-07-01 10:51:57 +09:00
90 changed files with 4706 additions and 2209 deletions
+25 -26
View File
@@ -5,42 +5,41 @@ import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { ClientIpGuard } from "@/entities/ip";
import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
import { PromptProvider, StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog";
import { PromptProvider, StoreProvider } from "@/shared/providers";
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<ClientIpGuard>
<StoreProvider>
<PromptProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>
<DndProvider
backend={MultiBackend}
options={getBackendOptions()}
>
{children}
</DndProvider>
</MenuWrapper>
<AlertDialog />
<PromptDialog />
<ConfirmDialog />
</AuthWrapper>
</ConfirmProvider>
</PromptProvider>
</StoreProvider>
<div className="text-2xl">
<ReactQueryDevtools initialIsOpen={false} />
</div>
</ClientIpGuard>
<StoreProvider>
<PromptProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>
<DndProvider
backend={MultiBackend}
options={getBackendOptions()}
>
{children}
</DndProvider>
</MenuWrapper>
<AlertDialog />
<PromptDialog />
<ConfirmDialog />
<SelectPromptDialog />
</AuthWrapper>
</ConfirmProvider>
</PromptProvider>
</StoreProvider>
<div className="text-2xl">
<ReactQueryDevtools initialIsOpen={false} />
</div>
</QueryClientProvider>
);
};
@@ -10,6 +10,7 @@ export interface GroupUserListResponseDto {
mode: string;
groupId: string;
userUnqId: string;
userId: string;
userName: string;
ogdpSeCd: string;
ogdpSeNm: string;
-1
View File
@@ -7,4 +7,3 @@ export {
type IpList,
type IP,
} from "./model/ip-interface";
export { ClientIpGuard } from "./lib/client-ip-guard";
@@ -1,48 +0,0 @@
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { SiteApi } from "@/entities/site/@x/ip";
import { ENDPOINT, SITE_ID } from "@/shared/config";
import { IpApi } from "../api/ip-service";
type ClientIpResponse = {
originalIp: string;
normalizedIp: string;
};
const ClientIpGuard = ({ children }: { children: React.ReactNode }) => {
const queryClient = useQueryClient();
const [isAllowed, setIsAllowed] = useState(true);
useEffect(() => {
(async () => {
const response = await fetch("/api/getClientIp");
const { normalizedIp } = (await response.json()) as ClientIpResponse;
if (!normalizedIp || !SITE_ID) return;
const siteInfo = await queryClient.ensureQueryData({
queryKey: [ENDPOINT.CMS_SERVICE.SITES, SITE_ID],
queryFn: () => SiteApi.siteDetail(SITE_ID),
});
if (siteInfo.siteKndCd === "O") return;
const { content: ipList } = await IpApi.getIpList({
siteId: SITE_ID,
prmYn: "Y",
});
const allowedClientIp = ipList.some((ip) => ip.ipAddr === normalizedIp);
setIsAllowed(allowedClientIp);
})();
}, []);
if (!isAllowed) {
alert("허용되지 않은 IP로 접근하였습니다.");
return <></>;
}
return <>{children}</>;
};
export { ClientIpGuard };
+1
View File
@@ -13,4 +13,5 @@ export {
type PostUpdateFormData,
type PostService,
} from "./model/post-interface";
export { deleteReasonMessages } from "./model/delete-reason-messages";
export { PostPageGuard } from "./lib/post-page-guard";
@@ -0,0 +1,11 @@
export const deleteReasonMessages = {
"저작권 침해":
"타인의 저작물을 허락 없이 게시하는 경우 저작권 침해로 간주되어 삭제될 수 있습니다.",
명예훼손: "타인의 명예를 훼손하는 내용의 게시물은 삭제될 수 있습니다.",
"개인정보 노출": "타인의 개인정보를 노출하는 게시물도 삭제될 수 있습니다.",
"불법 정보 유통":
"불법적인 상품이나 서비스를 판매하거나 홍보하는 게시물은 삭제될 수 있습니다.",
"욕설 및 비방":
"심한 욕설이나 비방, 모욕적인 내용이 포함된 게시물은 삭제될 수 있습니다.",
"광고성 정보": "지나치게 광고성이 짙은 게시물은 삭제될 수 있습니다.",
};
+13 -3
View File
@@ -21,20 +21,28 @@ import {
Skeleton,
} from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import { PostApi, type PostParams } from "@/entities/post";
import {
PostApi,
deleteReasonMessages,
type PostParams,
} from "@/entities/post";
import { AuthorityButton } from "@/entities/menu-authority";
import { ENDPOINT } from "@/shared/config";
import {
useAlertStore,
useMenuStore,
usePrompt,
useSelectPrompt,
useMutationWrapper,
} from "@/shared/lib";
const selectData = Object.entries(deleteReasonMessages).map(
([label, value]) => ({ label, value }),
);
function FaqPage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const pathname = usePathname();
const { prompt } = usePrompt();
const { prompt } = useSelectPrompt(selectData);
const alert = useAlertStore((state) => state.setMessage);
const bbsInfo = useMenuStore((state) => state.bbsInfo);
@@ -75,11 +83,13 @@ function FaqPage({ bbsId }: { bbsId: string }) {
const handleDeletePost = async (pstNo: number) => {
const delRsn = await getPromptValue("삭제 사유를 입력해주세요.");
if (!delRsn) return;
deletePost({ pstNo, delRsn });
};
const onSelectedPostDelete = async () => {
const delRsn = await getPromptValue("삭제 사유를 입력해주세요.");
if (!delRsn) return;
await Promise.all(
selectedPostNumbers.map((pstNo) =>
PostApi.deletePost(parseInt(bbsId), pstNo, delRsn),
@@ -26,7 +26,7 @@ export const columns: ColumnDef<GroupUserListResponseDto>[] = [
enableHiding: false,
},
{
accessorKey: "groupId",
accessorKey: "userId",
header: "사용자ID",
},
{
@@ -57,7 +57,6 @@ function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getRowId: (row) => row.userUnqId,
},
dataTableOptions: {
viewPagination: true,
@@ -65,15 +64,18 @@ function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
});
const handleDeleteSelected = async () => {
const selectedRows = Object.keys(rowSelection);
const selectedRows = table.getSelectedRowModel().rows.map((row) => ({
groupId: row.original.groupId,
userUnqId: row.original.userUnqId,
}));
if (selectedRows.length === 0 || !groupId) return;
if (!(await confirm("사용자를 삭제하시겠습니까?"))) return;
try {
const usersToDelete = selectedRows.map((userUnqId) => ({
const usersToDelete = selectedRows.map((row) => ({
mode: "D",
groupId,
userUnqId,
groupId: row.groupId,
userUnqId: row.userUnqId,
}));
await GroupUsersApi.saveGroupUser(usersToDelete);
@@ -90,6 +92,7 @@ function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
useEffect(() => {
setPagination({ ...pagination, pageIndex: 0 });
setRowSelection({});
}, [groupId]);
return (
-1
View File
@@ -1 +0,0 @@
export { LoginlogPage } from "./ui/loginlog-page";
+12 -1
View File
@@ -75,6 +75,9 @@ function MenuPage() {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
});
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
});
},
});
@@ -91,6 +94,9 @@ function MenuPage() {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
});
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
});
},
});
@@ -101,6 +107,9 @@ function MenuPage() {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
});
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
});
},
});
@@ -118,8 +127,10 @@ function MenuPage() {
menuLnkgTypeCd: menuInfo?.menuLnkgTypeCd ?? MENU_TYPE.EMPTY,
iconNm: menuInfo?.iconName,
otptYn: menuInfo?.otptYn ?? "Y",
evlUseYn: menuInfo?.evlUseYn ?? "Y",
evlUseYn: menuInfo?.evlUseYn ?? "N",
qrcd: menuInfo?.qrcd ?? "N",
useYn: menuInfo?.useYn ?? "Y",
newWndwYn: menuInfo?.newWndwYn ?? "N",
},
});
@@ -0,0 +1,81 @@
import React, { useEffect, useRef, useState } from "react";
import { useQRCode } from "next-qrcode";
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@common/components";
import { useClipboard } from "@frontend-opensource/use-react-hooks";
function QRCodeDialog({ url }: { url: string }) {
const { Canvas } = useQRCode();
const { copied: isCopied, copyText } = useClipboard();
const canvasRef = useRef<HTMLDivElement>(null);
const [originUrl, setOriginUrl] = useState("");
const qrCodeUrl = originUrl + url;
const handleDownload = () => {
const canvas = canvasRef.current?.querySelector("canvas");
if (!canvas) return;
const url = canvas.toDataURL("image/png");
const link = document.createElement("a");
link.href = url;
link.download = "qr-code.png";
link.click();
};
useEffect(() => {
if (typeof window === "undefined") return;
setOriginUrl(window.location.origin);
}, []);
return (
<Dialog>
<DialogTrigger asChild>
<Button disabled={!url}>QR 조회</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>QR 코드 스캔</DialogTitle>
</DialogHeader>
<div className="flex items-center justify-center p-4">
<div ref={canvasRef}>
<Canvas
text={url}
options={{
errorCorrectionLevel: "M",
margin: 1,
width: 200,
}}
/>
</div>
</div>
<div className="flex items-center justify-between rounded-sm bg-accent p-2 text-[1.3rem] text-accent-foreground">
<p className="break-all px-2">{qrCodeUrl}</p>
<Tooltip open={isCopied}>
<TooltipTrigger asChild>
<button
onClick={() => copyText(qrCodeUrl)}
className="size-10 shrink-0 rounded-sm hover:bg-[rgba(0,0,0,0.1)]"
>
<i className="diveicon di-clipboard" />
</button>
</TooltipTrigger>
<TooltipContent>
<span className="text-base">복사완료</span>
</TooltipContent>
</Tooltip>
</div>
<Button onClick={handleDownload}>QR 코드 다운로드</Button>
</DialogContent>
</Dialog>
);
}
export { QRCodeDialog };
+45 -24
View File
@@ -1,5 +1,5 @@
import dayjs from "dayjs";
import { type UseFormReturn } from "react-hook-form";
import type { UseFormReturn } from "react-hook-form";
import {
DatePicker,
IconFinder,
@@ -21,6 +21,7 @@ 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";
import { QRCodeDialog } from "./qrcode-dialog";
type TypeMenuFormProps = {
form: UseFormReturn<MenuFormData>;
@@ -33,6 +34,7 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
};
const handleContentSelect = (content: Contents) => {
form.setValue("urlPath", `/cms/cntns/${content.contsSn}`);
form.setValue("menuLnkgSn", content.contsSn);
form.setValue("menuLnkgSnNm", content.contsTtl);
};
@@ -43,6 +45,10 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
};
const handleBoardSelect = (board: BoardPopup) => {
form.setValue(
"urlPath",
`/cms/${board.bbsTypeCd.toLowerCase()}/${board.bbsId}`,
);
form.setValue("menuLnkgSn", board.bbsId);
form.setValue("menuLnkgSnNm", board.bbsNm);
};
@@ -52,6 +58,14 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
form.setValue("menuPicDsgnYmd", dayjs(date).format("YYYYMMDD"));
};
const getUrlPathPlaceholder = () => {
if (menuType === "CONTENTS") return "콘텐츠 선택 시 자동으로 입력됩니다.";
if (menuType === "BOARD") return "게시판 선택 시 자동으로 입력됩니다.";
if (menuType === "INSIDE")
return "메뉴에 연결된 페이지의 경로를 입력하십시오.";
return "'http://' 또는 'https://'를 포함하여 입력하십시오.";
};
return (
<Table variant="secondary" className="-mt-[0.1rem]">
<colgroup>
@@ -91,7 +105,10 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
</TableCell>
</TableRow>
)}
{(menuType === "INSIDE" || menuType === "OUTSIDE") && (
{(menuType === "CONTENTS" ||
menuType === "BOARD" ||
menuType === "INSIDE" ||
menuType === "OUTSIDE") && (
<TableRow>
<TableHead>연결 URL</TableHead>
<TableCell className="border">
@@ -105,10 +122,9 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder={
menuType === "INSIDE"
? "메뉴에 연결된 페이지의 경로를 입력하십시오."
: "'http://' 또는 'https://'를 포함하여 입력하십시오."
placeholder={getUrlPathPlaceholder()}
disabled={
menuType === "CONTENTS" || menuType === "BOARD"
}
/>
</FormControl>
@@ -293,25 +309,30 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
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 className="flex items-center gap-4">
<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>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="qrcdN" />
<Label htmlFor="qrcdN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
</FormItem>
</RadioGroup>
</FormControl>
</FormItem>
{field.value === "Y" && (
<QRCodeDialog url={form.watch("urlPath") ?? ""} />
)}
</div>
)}
/>
</TableCell>
+7 -2
View File
@@ -23,13 +23,14 @@ import {
} from "@common/components";
import {
PostApi,
deleteReasonMessages,
type DeletePost,
type RecentPost,
type RecentPostParams,
} from "@/entities/post";
import { BoardApi } from "@/entities/board";
import { AuthorityButton } from "@/entities/menu-authority";
import { useAlertStore, usePrompt, useDataTable } from "@/shared/lib";
import { useAlertStore, useSelectPrompt, useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { columns } from "../model/table-columns";
import { MovePostDialog } from "./move-post-dialog";
@@ -39,9 +40,13 @@ type DateRange = {
to?: Date | undefined;
};
const selectData = Object.entries(deleteReasonMessages).map(
([label, value]) => ({ label, value }),
);
function RecentPage() {
const router = useRouter();
const { prompt } = usePrompt();
const { prompt } = useSelectPrompt(selectData);
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const [dateRange, setDateRange] = useState<DateRange>();
+9 -6
View File
@@ -18,12 +18,8 @@ function ModifyPage({ siteId }: { siteId: string }) {
queryKey: [ENDPOINT.CMS_SERVICE.SITES, siteId],
queryFn: () => SiteApi.siteDetail(siteId),
select: (data) => {
const keys = Object.keys(siteFormSchema.shape);
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key]) => keys.includes(key)),
);
return {
...filteredData,
...data,
faxNo: data.faxNumber,
bscSiteYn: data.basicSiteYn,
};
@@ -50,7 +46,14 @@ function ModifyPage({ siteId }: { siteId: string }) {
const form = useForm<SiteFormData>({
resolver: zodResolver(siteFormSchema),
values: siteDetail as SiteFormData,
values: {
...siteDetail,
siteId: siteDetail?.siteId ?? "",
siteNm: siteDetail?.siteNm ?? "",
siteKndCd: siteDetail?.siteKndCd ?? "",
bscSiteYn: siteDetail?.bscSiteYn ?? "",
useYn: siteDetail?.useYn ?? "",
},
});
return (
+1
View File
@@ -82,6 +82,7 @@ function SiteForm({ form, siteId, ...props }: SiteFormProps) {
<CodeSelect
{...field}
upCd="SITEKNDCD"
value={field.value ?? ""}
onValueChange={field.onChange}
/>
</FormControl>
+3
View File
@@ -1,6 +1,9 @@
import { Client } from "@common/network";
import { BASE_URL } from "../config/api";
import { SITE_ID } from "../config/env";
import { setCookie } from "../lib";
setCookie("site-id-key", SITE_ID);
export const client = Client.getInstance({
baseURL: BASE_URL,
+1
View File
@@ -58,6 +58,7 @@ export const ENDPOINT = {
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
POSTS: "/board-service/api/v1/posts",
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
POST_EVALUATION: "/board-service/api/v1/pstEvl",
},
};
+1
View File
@@ -5,3 +5,4 @@ export { useDataTable } from "./use-data-table";
export { useMenuStore } from "./use-menu-store";
export { useUserStore } from "./use-user-store";
export { usePrompt } from "./use-prompt";
export { useSelectPrompt } from "./use-select-prompt";
@@ -0,0 +1,48 @@
import { useContext, useEffect } from "react";
import { PromptContext } from "../providers/prompt-provider";
type SelectData = {
label: string;
value: string;
};
export const useSelectPrompt = (data?: SelectData[]) => {
const {
selectData,
selectionGuide,
resolve,
setResolve,
setSelectData,
setSelectionGuide,
} = useContext(PromptContext);
const prompt = (message: string) => {
setSelectionGuide(message);
return new Promise<{ resolved: boolean; value?: string }>((resolve) => {
setResolve(() => resolve);
});
};
const onSubmit = (value: string) => {
resolve?.({ resolved: true, value });
setSelectionGuide("");
};
const onCancel = () => {
resolve?.({ resolved: false });
setSelectionGuide("");
};
useEffect(() => {
if (data) setSelectData(data);
}, [data]);
return {
selectData,
selectionGuide,
isOpen: !!selectionGuide,
prompt,
onSubmit,
onCancel,
};
};
@@ -1,7 +1,14 @@
import { createContext, useState } from "react";
type SelectData = {
label: string;
value: string;
};
interface PromptContext {
message?: string;
selectData?: SelectData[];
selectionGuide?: string;
resolve?: ({
resolved,
value,
@@ -10,18 +17,26 @@ interface PromptContext {
value?: string;
}) => void;
setMessage: (message: string) => void;
setSelectData: (selectData: SelectData[]) => void;
setSelectionGuide: (message: string) => void;
setResolve: (resolve: () => void) => void;
}
export const PromptContext = createContext<PromptContext>({
message: "",
selectData: [],
selectionGuide: "",
resolve() {},
setMessage() {},
setSelectData() {},
setSelectionGuide() {},
setResolve() {},
});
export const PromptProvider = ({ children }: { children: React.ReactNode }) => {
const [message, setMessage] = useState<string>();
const [selectData, setSelectData] = useState<SelectData[]>([]);
const [selectionGuide, setSelectionGuide] = useState<string>();
const [resolve, setResolve] =
useState<
({ resolved, value }: { resolved: boolean; value?: string }) => void
@@ -29,7 +44,16 @@ export const PromptProvider = ({ children }: { children: React.ReactNode }) => {
return (
<PromptContext.Provider
value={{ message, resolve, setMessage, setResolve }}
value={{
message,
selectData,
selectionGuide,
resolve,
setMessage,
setSelectData,
setSelectionGuide,
setResolve,
}}
>
{children}
</PromptContext.Provider>
+5 -1
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
@@ -13,6 +13,10 @@ function PromptDialog() {
const { message, isOpen, onSubmit, onCancel } = usePrompt();
const [value, setValue] = useState("");
useEffect(() => {
if (isOpen) setValue("");
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onCancel}>
<DialogContent aria-describedby={undefined}>
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogClose,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Textarea,
} from "@common/components";
import { useSelectPrompt } from "../lib/use-select-prompt";
function SelectPromptDialog() {
const { selectData, selectionGuide, isOpen, onSubmit, onCancel } =
useSelectPrompt();
const [value, setValue] = useState("");
useEffect(() => {
if (isOpen) setValue("");
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onCancel}>
<DialogContent aria-describedby={undefined}>
<div className="flex flex-col gap-6 pb-4 pt-8">
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
{selectionGuide}
</p>
<div className="flex flex-col gap-2">
<Select onValueChange={setValue}>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{selectData?.map((data) => (
<SelectItem key={data.value} value={data.value}>
{data.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Textarea
value={value}
className="!text-[1.3rem] !leading-6"
onChange={(e) => setValue(e.target.value)}
/>
</div>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button onClick={() => onSubmit(value)}>확인</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={onCancel} color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { SelectPromptDialog };
@@ -0,0 +1,14 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
PostEvaluationPayload,
PostEvaluationService,
} from "../model/post-evaluation-interface";
export class PostEvaluationApiService implements PostEvaluationService {
public async postEvaluation(payload: PostEvaluationPayload) {
await client.post(ENDPOINT.BOARD_SERVICE.POST_EVALUATION, payload);
}
}
export const PostEvaluationApi = new PostEvaluationApiService();
@@ -0,0 +1,10 @@
export interface PostEvaluationPayload {
pstNo: number;
evlIp?: string;
evlDt?: string;
evlScr: number;
}
export interface PostEvaluationService {
postEvaluation: (payload: PostEvaluationPayload) => Promise<void>;
}
+7 -2
View File
@@ -1,6 +1,7 @@
import { useRouter, usePathname } from "next/navigation";
import { Button } from "@common/components";
import { useMenuStore, usePrompt, useUserStore } from "@/shared/lib";
import { deleteReasonMessages } from "@/entities/post";
import { useMenuStore, useSelectPrompt, useUserStore } from "@/shared/lib";
type PostActionProps = {
bbsId: string;
@@ -9,10 +10,14 @@ type PostActionProps = {
handleDelete: (delRsn?: string) => Promise<void>;
};
const selectData = Object.entries(deleteReasonMessages).map(
([label, value]) => ({ label, value }),
);
function PostAction({ children, handleDelete }: PostActionProps) {
const router = useRouter();
const pathname = usePathname();
const { prompt } = usePrompt();
const { prompt } = useSelectPrompt(selectData);
const bbsInfo = useMenuStore((state) => state.bbsInfo);
const userInfo = useUserStore((state) => state.userInfo);
+7 -2
View File
@@ -25,19 +25,24 @@ import { AuthorityButton } from "@/entities/menu-authority";
import {
PostApi,
PostPageGuard,
deleteReasonMessages,
type Post,
type PostParams,
} from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useMenuStore, usePrompt } from "@/shared/lib";
import { useMenuStore, useSelectPrompt } from "@/shared/lib";
import { GridViewDataTable } from "@/shared/ui/grid-view-data-table";
import { columns as tableColumns } from "../model/post-table-columns";
import { createColumns as createGridViewColumns } from "../model/grid-view-table-columns";
const selectData = Object.entries(deleteReasonMessages).map(
([label, value]) => ({ label, value }),
);
function PostPage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const pathname = usePathname();
const { prompt } = usePrompt();
const { prompt } = useSelectPrompt(selectData);
const bbsInfo = useMenuStore((state) => state.bbsInfo);
const thumbRefs = useRef<Map<number, string>>(new Map());
+69 -3
View File
@@ -5,14 +5,21 @@ import Image from "next/image";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Button, Skeleton } from "@common/components";
import {
Button,
Label,
RadioGroup,
RadioGroupItem,
Skeleton,
} from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import { PostApi } from "@/entities/post";
import { useAlertStore, useMutationWrapper, useMenuStore } from "@/shared/lib";
import { BoardApi } from "@/entities/board";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper, useMenuStore } from "@/shared/lib";
import { PostAction } from "./post-action";
import { PostComment } from "./post-comment";
import { BoardApi } from "@/entities/board";
import { PostEvaluationApi } from "../api/post-evaluation-service";
function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const router = useRouter();
@@ -20,6 +27,7 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const { bbsInfo, setBbsInfo } = useMenuStore((state) => state);
const [filename, setFilename] = useState("");
const [evlScr, setEvlScr] = useState(5);
const { data: boardInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.BBS, bbsId],
@@ -72,6 +80,17 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
},
});
const { mutate: postEvaluation } = useMutationWrapper({
mutationFn: (evlScr: number) =>
PostEvaluationApi.postEvaluation({
pstNo: parseInt(pstNo),
evlScr,
}),
onSuccess: () => {
alert("게시물 평가가 완료되었습니다.");
},
});
const handleDownload = async () => {
if (!downloadResponse) return;
try {
@@ -187,6 +206,53 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
</>
)
)}
{bbsInfo?.dgstfnEvlUseYn === "Y" && (
<>
<hr />
<div className="mt-8 flex items-center gap-2 px-4">
<p>
이 페이지에서 제공하는
<br />
정보에 대하여 만족하시나요?
</p>
<div className="ml-4 rounded-sm border border-border bg-accent px-8 py-4">
<RadioGroup
value={evlScr.toString()}
onValueChange={(value) => setEvlScr(parseInt(value))}
className="flex items-center space-x-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="5" id="evl5" />
<Label htmlFor="evl5">매우 만족 😊</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="4" id="evl4" />
<Label htmlFor="evl4">만족 🙂</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="3" id="evl3" />
<Label htmlFor="evl3">보통 😐</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="2" id="evl2" />
<Label htmlFor="evl2">불만족 🙁</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="1" id="evl1" />
<Label htmlFor="evl1">매우 불만족 😞</Label>
</div>
</RadioGroup>
</div>
<Button
size="lg"
color="white"
onClick={() => postEvaluation(evlScr)}
>
평가하기
</Button>
</div>
</>
)}
</div>
</PostAction>
<PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />