Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs_cs

This commit is contained in:
2025-05-20 10:18:59 +09:00
75 changed files with 2237 additions and 338 deletions
@@ -0,0 +1,10 @@
import { ModifyPage } from "@/pages/(pst)/faq";
export default async function PstBbsFaqPostModifyPage({
params,
}: {
params: Promise<{ bbsId: string; pstNo: string }>;
}) {
const { bbsId, pstNo } = await params;
return <ModifyPage bbsId={bbsId} pstNo={pstNo} />;
}
@@ -0,0 +1,10 @@
import { FaqPage } from "@/pages/(pst)/faq";
export default async function PstBbsFaqPage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <FaqPage bbsId={bbsId} />;
}
@@ -0,0 +1,10 @@
import { CreatePage } from "@/pages/(pst)/faq";
export default async function PstBbsFaqRegisterPage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <CreatePage bbsId={bbsId} />;
}
@@ -0,0 +1,10 @@
import { ModifyPage } from "@/pages/(pst)/movie";
export default async function PstBbsMoviePostModifyPage({
params,
}: {
params: Promise<{ bbsId: string; pstNo: string }>;
}) {
const { bbsId, pstNo } = await params;
return <ModifyPage bbsId={bbsId} pstNo={pstNo} />;
}
@@ -0,0 +1,10 @@
import { MoviePost } from "@/pages/(pst)/movie";
export default async function PstBbsMoviePostPage({
params,
}: {
params: Promise<{ bbsId: string; pstNo: string }>;
}) {
const { bbsId, pstNo } = await params;
return <MoviePost bbsId={bbsId} pstNo={pstNo} />;
}
@@ -0,0 +1,10 @@
import { MoviePage } from "@/pages/(pst)/movie";
export default async function PstBbsMoviePage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <MoviePage bbsId={bbsId} />;
}
@@ -0,0 +1,10 @@
import { CreatePage } from "@/pages/(pst)/movie";
export default async function PstBbsMovieRegisterPage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <CreatePage bbsId={bbsId} />;
}
@@ -52,7 +52,15 @@ export class AttachmentApiService implements AttachmentService {
return URL.createObjectURL(blob);
}
public async getMovieThumbnailUrl(atflId: string): Promise<string> {
public async getStreamVideoUrlByAtflId(atflId: string): Promise<string> {
const response = await fetch(
`${ENDPOINT.ATTACH_SERVICE.STREAM_VIDEO}/${atflId}`,
);
const blob = await response.blob();
return URL.createObjectURL(blob);
}
public async getMovieThumbnailUrlByAtflId(atflId: string): Promise<string> {
const response = await fetch(
`${ENDPOINT.ATTACH_SERVICE.MOVIE_THUMBNAIL}/${atflId}`,
);
@@ -60,6 +68,14 @@ export class AttachmentApiService implements AttachmentService {
return URL.createObjectURL(blob);
}
public async getMovieThumbnailUrlByAtflCd(atflCd: string): Promise<string> {
const response = await fetch(
`${ENDPOINT.ATTACH_SERVICE.MOVIE_THUMBNAIL}/atflCd/${atflCd}`,
);
const blob = await response.blob();
return URL.createObjectURL(blob);
}
public async downloadAttachmentByAtflCd(
atflCd: string,
): Promise<AxiosResponse> {
@@ -40,6 +40,8 @@ export interface AttachmentService {
deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>;
getImageUrlByAtflId: (atflId: string) => Promise<string>;
getImageUrlByAtflCd: (atflCd: string) => Promise<string>;
getMovieThumbnailUrl: (atflId: string) => Promise<string>;
getStreamVideoUrlByAtflId: (atflId: string) => Promise<string>;
getMovieThumbnailUrlByAtflId: (atflId: string) => Promise<string>;
getMovieThumbnailUrlByAtflCd: (atflCd: string) => Promise<string>;
downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>;
}
@@ -14,7 +14,7 @@ import {
export class PostApiService implements PostService {
public async getPostList(
bbsId: string,
bbsId: number,
params: PostParams,
): Promise<PostList> {
const response = await client.get<PostList>(
@@ -24,7 +24,7 @@ export class PostApiService implements PostService {
return response.data;
}
public async getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo> {
public async getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo> {
const response = await client.get<PostInfo>(
`${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`,
);
@@ -41,8 +41,8 @@ export class PostApiService implements PostService {
}
public async updatePost(
bbsId: string,
pstNo: string,
bbsId: number,
pstNo: number,
data: PostUpdateFormData,
): Promise<Post> {
const validatedData = postUpdateFormSchema.parse(data);
@@ -54,8 +54,8 @@ export class PostApiService implements PostService {
}
public async deletePost(
bbsId: string,
pstNo: string,
bbsId: number,
pstNo: number,
pstPswd?: string,
): Promise<void> {
await client.delete(
@@ -8,13 +8,15 @@ export interface PostParams extends PageableParams {
upndFixYn?: string;
prdPstgYn?: string;
siteId?: string;
bbsTypeCd?: string;
}
export interface Post {
bbsId: number;
rowNo: number;
bbsId: number;
pstNo: number;
pstTtl: string;
pstCn: string;
inqNocs: number;
rfrncUrl: string;
popupYn: string;
@@ -73,13 +75,13 @@ export type PostFormData = z.infer<typeof postFormSchema>;
export type PostUpdateFormData = z.infer<typeof postUpdateFormSchema>;
export interface PostService {
getPostList(bbsId: string, params: PostParams): Promise<PostList>;
getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo>;
getPostList(bbsId: number, params: PostParams): Promise<PostList>;
getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo>;
createPost(data: PostFormData): Promise<Post>;
updatePost(
bbsId: string,
pstNo: string,
bbsId: number,
pstNo: number,
data: PostUpdateFormData,
): Promise<Post>;
deletePost(bbsId: string, pstNo: string, pstPswd?: string): Promise<void>;
deletePost(bbsId: number, pstNo: number, pstPswd?: string): Promise<void>;
}
@@ -0,0 +1,3 @@
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
export { FaqPage } from "./ui/faq-page";
@@ -0,0 +1,43 @@
"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 { PostForm } from "@/widgets/post";
import { PostApi, postFormSchema, type PostFormData } from "@/entities/post";
import { useAlertStore, useUserStore } from "@/shared/lib";
function CreatePage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const { userInfo } = useUserStore((state) => state);
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createPost } = useMutation({
mutationFn: (data: PostFormData) => PostApi.createPost(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<PostFormData>({
resolver: zodResolver(postFormSchema),
values: {
bbsId: parseInt(bbsId),
pstTtl: "",
wrtrNm: userInfo?.userName ?? "",
pstPswd: userInfo?.userUnqId ?? "",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">FAQ 게시글 등록</h3>
</div>
<PostForm form={form} handleSave={createPost} />
</div>
);
}
export { CreatePage };
@@ -0,0 +1,316 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Checkbox,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
} from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import { PostApi, type PostParams } from "@/entities/post";
import { useAlertStore, useMenuStore, useUserStore } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import dayjs from "dayjs";
import React from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
function FaqPage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const pathname = usePathname();
const { confirm } = useConfirm();
const { userInfo } = useUserStore((state) => state);
const { setMessage: alert } = useAlertStore((state) => state);
const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state);
const [postParams, setPostParams] = useState<PostParams>({
keywordType: "title",
keyword: "",
});
const [selectedPostNumbers, setSelectedPostNumbers] = useState<number[]>([]);
const {
data: postList,
isFetched,
refetch,
} = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsInfo?.bbsTypeCd],
queryFn: () =>
PostApi.getPostList(parseInt(bbsId), {
...postParams,
bbsTypeCd: bbsInfo?.bbsTypeCd,
}),
enabled: !!bbsInfo?.bbsTypeCd,
});
const { mutate: deletePost } = useMutation({
mutationFn: ({ pstNo, pstPswd }: { pstNo: number; pstPswd?: string }) =>
PostApi.deletePost(parseInt(bbsId), pstNo, pstPswd),
onSuccess: () => {
alert("삭제되었습니다.");
refetch();
},
});
const handleDeletePost = async (pstNo: number) => {
if (await confirm("삭제하시겠습니까?")) {
deletePost({ pstNo, pstPswd: userInfo?.userUnqId });
}
};
const onSelectedPostDelete = async () => {
if (await confirm("삭제하시겠습니까?")) {
await Promise.all(
selectedPostNumbers.map((pstNo) =>
PostApi.deletePost(parseInt(bbsId), pstNo, userInfo?.userUnqId),
),
);
refetch();
setSelectedPostNumbers([]);
alert("삭제되었습니다.");
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
const handleCheckboxChange = (pstNo: number, checked: boolean | string) => {
setSelectedPostNumbers(
checked === true
? [...selectedPostNumbers, pstNo]
: selectedPostNumbers.filter((num) => num !== pstNo),
);
};
const handleAllCheckboxChange = (checked: boolean | string) => {
setSelectedPostNumbers(
checked === true ? postList?.content.map((item) => item.pstNo) || [] : [],
);
};
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 className="mr-2 text-label">검색</Label>
<Select
value={postParams.keywordType}
onValueChange={(keywordType) => {
setPostParams({ ...postParams, keywordType });
}}
>
<SelectTrigger className="h-[3.2rem] w-[10rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="title">제목</SelectItem>
<SelectItem value="all">제목+내용</SelectItem>
<SelectItem value="writer">작성자</SelectItem>
</SelectContent>
</Select>
<Input
value={postParams.keyword}
onChange={(e) =>
setPostParams({ ...postParams, keyword: e.target.value })
}
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
/>
<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">
{pageMenuAuthority?.regAuthrtYn === "Y" && (
<Button onClick={() => router.push(`${pathname}/register`)}>
등록
</Button>
)}
{pageMenuAuthority?.delAuthrtYn === "Y" && (
<Button
color="red"
onClick={onSelectedPostDelete}
disabled={!selectedPostNumbers.length}
>
선택삭제
</Button>
)}
</div>
</div>
</div>
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
<div className="mb-4 flex items-center gap-2 rounded-sm border border-border bg-background p-4">
<Checkbox
id="all-select"
checked={selectedPostNumbers.length === postList?.content.length}
onClick={(e) => e.stopPropagation()}
onCheckedChange={handleAllCheckboxChange}
/>
<Label htmlFor="all-select" className="text-2xl">
전체선택
</Label>
</div>
{isFetched ? (
<Accordion
type="multiple"
className="secondary flex flex-col gap-4"
>
{postList?.content.map((item) => (
<div key={item.pstNo} className="flex items-start gap-2">
<Checkbox
id={item.pstNo.toString()}
checked={selectedPostNumbers.includes(item.pstNo)}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
handleCheckboxChange(item.pstNo, checked)
}
className="mx-2 my-6"
/>
<AccordionItem
value={item.pstNo.toString()}
className="w-full"
>
<AccordionTrigger>
<div className="flex flex-1 items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-primary">Q</span>
<span>{item.pstTtl}</span>
</div>
<span className="px-4 text-[1.2rem] font-normal text-label">
등록일: {dayjs(item.regDt).format("YYYY-MM-DD")}
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="flex items-start gap-4">
<span className="text-[1.4rem] font-bold text-label">
A
</span>
<div className="flex-1">
<pre className="whitespace-pre-wrap font-noto text-[1.4rem] tracking-tighter text-foreground">
{item.pstCn}
</pre>
<Attachment atflCd={item.atflCd} />
</div>
<div className="flex justify-end gap-2">
<Button
size="icon"
color="white"
onClick={() =>
router.push(`${pathname}/${item.pstNo}`)
}
>
<i className="diveicon di-pencil" />
</Button>
<Button
size="icon"
color="white"
onClick={() => handleDeletePost(item.pstNo)}
>
<i className="diveicon di-trash" />
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</div>
))}
</Accordion>
) : (
<Skeleton className="h-[36rem] w-full" />
)}
</div>
</div>
</div>
);
}
function Attachment({ atflCd }: { atflCd: string }) {
const [filename, setFilename] = useState("");
const { data: downloadResponse, isFetching } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, atflCd],
queryFn: () => AttachmentApi.downloadAttachmentByAtflCd(atflCd),
enabled: !!atflCd,
});
const handleDownload = async () => {
if (!downloadResponse) return;
try {
const blob = new Blob([downloadResponse.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename || "download";
document.body.appendChild(link);
link.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
} catch (error) {
console.error(error);
}
};
useEffect(() => {
if (!downloadResponse) return;
try {
let filename = "";
const contentDisposition =
downloadResponse.headers["content-disposition"];
if (contentDisposition) {
const filenameMatch = contentDisposition.match(
/filename\*=UTF-8''([^;]+)/,
);
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1]);
}
}
setFilename(filename);
} catch {
setFilename("");
}
}, [downloadResponse]);
return isFetching ? (
<div className="my-8">
<Skeleton className="h-[2.5rem] w-[32rem]" />
</div>
) : (
filename && (
<div className="my-8 flex items-center gap-4">
<span className="font-bold text-label">첨부파일</span>
<Button
size="sm"
color="white"
className="flex w-fit items-center gap-2"
onClick={handleDownload}
>
<i className="diveicon di-download" />
<span>{filename}</span>
</Button>
</div>
)
);
}
export { FaqPage };
@@ -0,0 +1,53 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { PostForm } from "@/widgets/post";
import {
PostApi,
postUpdateFormSchema,
type PostUpdateFormData,
} from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
});
const form = useForm<PostUpdateFormData>({
resolver: zodResolver(postUpdateFormSchema),
values: {
...postInfo,
pstTtl: postInfo?.pstTtl ?? "",
pstPswd: userInfo?.userUnqId ?? "",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">FAQ 게시글 수정</h3>
</div>
<PostForm form={form} handleSave={updatePost} />
</div>
);
}
export { ModifyPage };
@@ -20,12 +20,12 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(bbsId, pstNo, data),
PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
@@ -0,0 +1,4 @@
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
export { MoviePage } from "./ui/movie-page";
export { MoviePost } from "./ui/movie-post";
@@ -0,0 +1,43 @@
"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 { PostForm } from "@/widgets/post";
import { PostApi, postFormSchema, type PostFormData } from "@/entities/post";
import { useAlertStore, useUserStore } from "@/shared/lib";
function CreatePage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const { userInfo } = useUserStore((state) => state);
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createPost } = useMutation({
mutationFn: (data: PostFormData) => PostApi.createPost(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<PostFormData>({
resolver: zodResolver(postFormSchema),
values: {
bbsId: parseInt(bbsId),
pstTtl: "",
wrtrNm: userInfo?.userName ?? "",
pstPswd: userInfo?.userUnqId ?? "",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">동영상 게시글 등록</h3>
</div>
<PostForm form={form} handleSave={createPost} />
</div>
);
}
export { CreatePage };
@@ -0,0 +1,53 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { PostForm } from "@/widgets/post";
import {
PostApi,
postUpdateFormSchema,
type PostUpdateFormData,
} from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
});
const form = useForm<PostUpdateFormData>({
resolver: zodResolver(postUpdateFormSchema),
values: {
...postInfo,
pstTtl: postInfo?.pstTtl ?? "",
pstPswd: userInfo?.userUnqId ?? "",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">동영상 게시글 수정</h3>
</div>
<PostForm form={form} handleSave={updatePost} />
</div>
);
}
export { ModifyPage };
@@ -0,0 +1,3 @@
"use client";
export { PostPage as MoviePage } from "@/widgets/post";
@@ -0,0 +1,3 @@
"use client";
export { Post as MoviePost } from "@/widgets/post";
@@ -20,12 +20,12 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(bbsId, pstNo, data),
PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
@@ -20,12 +20,12 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(bbsId, pstNo, data),
PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
@@ -10,6 +10,7 @@ export const ENDPOINT = {
MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail",
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
DOWNLOAD: "/attach-service/api/v1/atflcd/download",
STREAM_VIDEO: "/attach-service/api/v1/stream/video",
},
USER_SERVICE: {
LOGIN: "/user-service/login",
@@ -7,18 +7,18 @@ export const usePrompt = () => {
const prompt = (message: string) => {
setMessage(message);
return new Promise<string>((resolve) => {
return new Promise<{ resolved: boolean; value?: string }>((resolve) => {
setResolve(() => resolve);
});
};
const onSubmit = (value: string) => {
resolve?.(value);
resolve?.({ resolved: true, value });
setMessage("");
};
const onCancel = () => {
resolve?.("");
resolve?.({ resolved: false });
setMessage("");
};
@@ -2,9 +2,15 @@ import { createContext, useState } from "react";
interface PromptContext {
message?: string;
resolve?: (value: string) => void;
resolve?: ({
resolved,
value,
}: {
resolved: boolean;
value?: string;
}) => void;
setMessage: (message: string) => void;
setResolve: (resolve: (value: string) => void) => void;
setResolve: (resolve: () => void) => void;
}
export const PromptContext = createContext<PromptContext>({
@@ -16,7 +22,10 @@ export const PromptContext = createContext<PromptContext>({
export const PromptProvider = ({ children }: { children: React.ReactNode }) => {
const [message, setMessage] = useState<string>();
const [resolve, setResolve] = useState<(value: string) => void>();
const [resolve, setResolve] =
useState<
({ resolved, value }: { resolved: boolean; value?: string }) => void
>();
return (
<PromptContext.Provider
@@ -56,7 +56,7 @@ function GridViewDataTable<T>({
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={cn(
"rounded-md border border-border bg-background p-4 shadow-md",
"overflow-hidden rounded-md border border-border bg-background p-4 shadow-md",
onRowClick && "cursor-pointer hover:bg-accent",
)}
>
@@ -1,11 +1,19 @@
import dayjs from "dayjs";
import Image from "next/image";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge, Checkbox, Skeleton } from "@common/components";
import { Badge, Checkbox, Skeleton, cn } from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import type { Post } from "@/entities/post";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { AttachmentApi } from "@/entities/attachment";
import type { BbsType } from "@/shared/store/menu-store";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData> {
bbsTypeCd?: BbsType;
}
}
export const createColumns = (
thumbRefs: React.MutableRefObject<Map<number, string>>,
@@ -22,68 +30,120 @@ export const createColumns = (
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => {
const { pstTtl, inqNocs } = row.original;
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
cell: ({ row, table }) => {
const { atflCd, pstNo, pstTtl, inqNocs, rgtrNm, regDt } = row.original;
const bbsTypeCd = table.options.meta?.bbsTypeCd;
if (bbsTypeCd === "BBSIMAGE") {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
{inqNocs >= 100 && <Badge color="red">HOT</Badge>}
{dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && (
<Badge color="blue">NEW</Badge>
)}
<span className="text-[1.6rem] font-bold text-label">
{pstTtl}
</span>
</div>
<ThumbImage
atflCd={atflCd}
pstNo={pstNo}
thumbRefs={thumbRefs}
bbsTypeCd={bbsTypeCd}
/>
{inqNocs >= 100 && <Badge color="red">HOT</Badge>}
{dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && (
<Badge color="blue">NEW</Badge>
)}
<div className="flex flex-col">
<span className="text-[1.4rem] font-bold text-label">
{rgtrNm}
</span>
<span className="text-[1.2rem] text-label">
{dayjs(regDt).format("YYYY-MM-DD")}
</span>
</div>
</div>
<span className="text-3xl font-bold leading-none text-label">
{pstTtl}
</span>
</div>
);
);
}
if (bbsTypeCd === "BBSMOVIE") {
return (
<div className="-m-4">
<ThumbImage
atflCd={atflCd}
pstNo={pstNo}
thumbRefs={thumbRefs}
bbsTypeCd={bbsTypeCd}
classes={{
image: "object-cover",
}}
/>
<div className="p-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
{inqNocs >= 100 && <Badge color="red">HOT</Badge>}
{dayjs().diff(dayjs(regDt), "hour") < 24 && (
<Badge color="blue">NEW</Badge>
)}
<span className="text-[1.6rem] font-bold text-label">
{pstTtl}
</span>
</div>
<div className="flex flex-col">
<span className="text-[1.4rem] font-bold text-label">
{rgtrNm}
</span>
<span className="text-[1.2rem] text-label">
{dayjs(regDt).format("YYYY-MM-DD")}
</span>
</div>
</div>
</div>
</div>
);
}
},
},
{
id: "atflCd",
cell: ({ row }) => {
const { atflCd, pstNo } = row.original;
return <ThumbImage atflCd={atflCd} pstNo={pstNo} thumbRefs={thumbRefs} />;
},
},
{
id: "rgtrNm",
cell: ({ row }) => {
return (
<span className="text-[1.4rem] font-bold text-label">
{row.original.rgtrNm}
</span>
);
},
},
{
id: "regDt",
cell: ({ row }) => (
<span className="text-[1.2rem] text-label">
{dayjs(row.original.regDt).format("YYYY-MM-DD")}
</span>
),
},
];
type ThumbImageProps = {
atflCd: string;
pstNo: number;
thumbRefs: React.MutableRefObject<Map<number, string>>;
bbsTypeCd?: string;
classes?: {
image?: string;
};
};
const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => {
const ThumbImage = ({
atflCd,
pstNo,
thumbRefs,
bbsTypeCd,
classes,
}: ThumbImageProps) => {
const prevImage = thumbRefs.current.get(pstNo);
const { data: imageUrl, isLoading } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd],
queryFn: () => AttachmentApi.getImageUrlByAtflCd(atflCd),
queryFn: () => {
if (bbsTypeCd === "BBSIMAGE") {
return AttachmentApi.getImageUrlByAtflCd(atflCd);
} else if (bbsTypeCd === "BBSMOVIE") {
return AttachmentApi.getMovieThumbnailUrlByAtflCd(atflCd);
}
},
select: (url) => {
if (url) thumbRefs.current.set(pstNo, url);
return url;
@@ -93,17 +153,17 @@ const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => {
if (isLoading)
return (
<div className="my-6 w-full">
<Skeleton className="mx-auto h-[16rem] w-full" />
<div className="h-[16rem] w-full">
<Skeleton className="h-full w-full" />
</div>
);
if (imageUrl)
return (
<div className="relative my-6 flex h-[16rem] w-full items-center justify-center">
<div className="relative flex h-[16rem] w-full items-center justify-center">
<Image
src={imageUrl}
className="object-contain"
className={cn("object-contain", classes?.image)}
fill
alt="썸네일 이미지"
/>
@@ -111,7 +171,7 @@ const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => {
);
return (
<div className="my-6 flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background">
<div className="flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background">
<i className="diveicon di-image text-3xl" />
</div>
);
@@ -34,7 +34,7 @@ export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [
return (
<Badge
color="orange"
className="flex size-10 items-center justify-center"
className="flex size-8 items-center justify-center"
>
<i className="diveicon di-volume-up-fill" />
</Badge>
@@ -35,7 +35,7 @@ function PostAction({ children, handleDelete }: PostActionProps) {
};
return (
<div>
<>
{children}
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
@@ -68,7 +68,7 @@ function PostAction({ children, handleDelete }: PostActionProps) {
삭제
</Button>
</div>
</div>
</>
);
}
@@ -89,7 +89,8 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) {
if (userInfo?.userUnqId) {
cmntPswd = userInfo?.userUnqId;
} else {
cmntPswd = await prompt("비밀번호를 입력해주세요.");
const { resolved, value } = await prompt("비밀번호를 입력해주세요.");
if (resolved) cmntPswd = value ?? "";
}
return cmntPswd;
};
@@ -24,11 +24,10 @@ import {
} from "@common/components";
import { PostApi, type Post, type PostParams } from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import type { BbsType } from "@/shared/store/menu-store";
import { useMenuStore, useUserStore } from "@/shared/lib";
import { GridViewDataTable } from "@/shared/ui/grid-view-data-table";
import { columns } from "../model/post-table-columns";
import { createColumns } from "../model/grid-view-table-columns";
import { columns as tableColumns } from "../model/post-table-columns";
import { createColumns as createGridViewColumns } from "../model/grid-view-table-columns";
function PostPage({ bbsId }: { bbsId: string }) {
const router = useRouter();
@@ -36,7 +35,6 @@ function PostPage({ bbsId }: { bbsId: string }) {
const { confirm } = useConfirm();
const { userInfo } = useUserStore((state) => state);
const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state);
const bbsType = bbsInfo?.bbsTypeCd as BbsType;
const [postParams, setPostParams] = useState<PostParams>({
keywordType: "title",
@@ -53,7 +51,7 @@ function PostPage({ bbsId }: { bbsId: string }) {
const { data: upndFixPostList } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, bbsInfo],
queryFn: () =>
PostApi.getPostList(bbsId, {
PostApi.getPostList(parseInt(bbsId), {
...postParams,
upndFixYn: bbsInfo?.upndFixUseYn,
}),
@@ -77,7 +75,7 @@ function PostPage({ bbsId }: { bbsId: string }) {
} = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId],
queryFn: () =>
PostApi.getPostList(bbsId, {
PostApi.getPostList(parseInt(bbsId), {
...postParams,
page: pagination.pageIndex,
size: pagination.pageSize,
@@ -85,9 +83,14 @@ function PostPage({ bbsId }: { bbsId: string }) {
enabled: false,
});
const isGridViewPage =
bbsInfo?.bbsTypeCd === "BBSIMAGE" || bbsInfo?.bbsTypeCd === "BBSMOVIE";
const table = useReactTable({
data: [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])],
columns,
data: isGridViewPage
? (postList?.content ?? [])
: [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])],
columns: isGridViewPage ? createGridViewColumns(thumbRefs) : tableColumns,
state: { pagination, rowSelection },
rowCount: postList?.totalElements ?? 0,
manualPagination: true,
@@ -95,19 +98,10 @@ function PostPage({ bbsId }: { bbsId: string }) {
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
meta: { bbsTypeCd: bbsInfo?.bbsTypeCd },
});
const gridViewTable = useReactTable({
data: postList?.content ?? [],
columns: createColumns(thumbRefs),
state: { pagination, rowSelection },
rowCount: postList?.totalElements ?? 0,
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
});
const DataTableComponent = isGridViewPage ? GridViewDataTable : DataTable;
const onPostDelete = async () => {
const selectedPosts = table
@@ -118,7 +112,7 @@ function PostPage({ bbsId }: { bbsId: string }) {
setRowSelection({});
await Promise.all(
selectedPosts.map((pstNo) =>
PostApi.deletePost(bbsId, pstNo.toString(), userInfo?.userUnqId),
PostApi.deletePost(parseInt(bbsId), pstNo, userInfo?.userUnqId),
),
);
refetch();
@@ -211,19 +205,11 @@ function PostPage({ bbsId }: { bbsId: string }) {
</div>
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
{isFetched ? (
bbsType === "BBSIMAGE" ? (
<GridViewDataTable
table={gridViewTable}
onRowClick={handleRowClick}
viewPagination
/>
) : (
<DataTable
table={table}
onRowClick={handleRowClick}
viewPagination
/>
)
<DataTableComponent
table={table}
onRowClick={handleRowClick}
viewPagination
/>
) : (
<Skeleton className="h-[36rem] w-full" />
)}
@@ -29,14 +29,17 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const { data: postData } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { data: downloadResponse, isFetching } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, postData?.atflCd],
queryFn: () =>
AttachmentApi.downloadAttachmentByAtflCd(postData?.atflCd as string),
enabled: !!postData?.atflCd && bbsInfo?.bbsTypeCd !== "BBSIMAGE",
enabled:
!!postData?.atflCd &&
bbsInfo?.bbsTypeCd !== "BBSIMAGE" &&
bbsInfo?.bbsTypeCd !== "BBSMOVIE",
});
const { data: imageUrl, isLoading } = useQuery({
@@ -46,8 +49,25 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
enabled: !!postData?.atflCd,
});
const { data: videoAttachment } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, postData?.atflCd],
queryFn: () => AttachmentApi.getAttachmentsInfo(postData?.atflCd as string),
select: (data) => data[0],
enabled: !!postData?.atflCd && bbsInfo?.bbsTypeCd === "BBSMOVIE",
});
const { data: videoUrl } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.STREAM_VIDEO, videoAttachment?.atflId],
queryFn: () =>
AttachmentApi.getStreamVideoUrlByAtflId(
videoAttachment?.atflId as string,
),
enabled: !!videoAttachment?.atflId,
});
const { mutate: deletePost } = useMutation({
mutationFn: (pstPswd: string) => PostApi.deletePost(bbsId, pstNo, pstPswd),
mutationFn: (pstPswd: string) =>
PostApi.deletePost(parseInt(bbsId), parseInt(pstNo), pstPswd),
onSuccess: () => {
alert("삭제되었습니다.", router.back);
},
@@ -58,7 +78,8 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
if (userInfo?.userUnqId) {
pstPswd = userInfo?.userUnqId;
} else {
pstPswd = await prompt("비밀번호를 입력해주세요.");
const { resolved, value } = await prompt("비밀번호를 입력해주세요.");
if (resolved) pstPswd = value ?? "";
}
deletePost(pstPswd);
};
@@ -142,6 +163,11 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
)}
</>
)}
{bbsInfo?.bbsTypeCd === "BBSMOVIE" && (
<div className="relative my-6 flex h-[56.25rem] w-[100rem]">
<video src={videoUrl} controls className="object-contain" />
</div>
)}
<pre className="font-noto min-h-[40rem] whitespace-pre-wrap p-8 text-2xl tracking-tighter text-foreground">
{postData?.pstCn ?? ""}
</pre>
@@ -50,7 +50,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({
imageUrl = await AttachmentApi.getImageUrlByAtflId(atflId);
}
if (bbsInfo?.bbsTypeCd === "BBSMOVIE") {
imageUrl = await AttachmentApi.getMovieThumbnailUrl(atflId);
imageUrl = await AttachmentApi.getMovieThumbnailUrlByAtflId(atflId);
}
setThumbnailUrl(imageUrl);
}
@@ -0,0 +1,10 @@
import { ModifyPage } from "@/pages/(post)/faq";
export default async function PstBbsFaqPostModifyPage({
params,
}: {
params: Promise<{ bbsId: string; pstNo: string }>;
}) {
const { bbsId, pstNo } = await params;
return <ModifyPage bbsId={bbsId} pstNo={pstNo} />;
}
@@ -0,0 +1,10 @@
import { FaqPage } from "@/pages/(post)/faq";
export default async function PstBbsFaqPage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <FaqPage bbsId={bbsId} />;
}
@@ -0,0 +1,10 @@
import { CreatePage } from "@/pages/(post)/faq";
export default async function PstBbsFaqRegisterPage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <CreatePage bbsId={bbsId} />;
}
@@ -0,0 +1,2 @@
import { RecentPage } from "@/pages/recent";
export default RecentPage;
+19 -12
View File
@@ -6,8 +6,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
import { StoreProvider } from "@/shared/providers";
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 { initializeNextClientConfig } from "@common/config";
@@ -19,17 +20,23 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
{children}
</DndProvider>
</MenuWrapper>
<AlertDialog />
<ConfirmDialog />
</AuthWrapper>
</ConfirmProvider>
<PromptProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>
<DndProvider
backend={MultiBackend}
options={getBackendOptions()}
>
{children}
</DndProvider>
</MenuWrapper>
<AlertDialog />
<PromptDialog />
<ConfirmDialog />
</AuthWrapper>
</ConfirmProvider>
</PromptProvider>
</StoreProvider>
</QueryClientProvider>
);
@@ -11,7 +11,7 @@ import type {
} from "../model/board-interface";
export class BoardApiService implements BoardService {
public async getBoardList(params: BoardParams): Promise<BoardList> {
public async getBoardList(params?: BoardParams): Promise<BoardList> {
const response = await client.get<BoardList>(
`${ENDPOINT.BOARD_SERVICE.BBS}`,
{ params },
+45 -16
View File
@@ -4,29 +4,43 @@ import {
postFormSchema,
postUpdateFormSchema,
type Post,
type MovePost,
type DeletePost,
type PostInfo,
type PostList,
type RecentPostList,
type PostParams,
type PostService,
type RecentPostParams,
type PostFormData,
type PostUpdateFormData,
type PostService,
} from "../model/post-interface";
export class PostApiService implements PostService {
public async getPostList(
bbsId: string,
bbsId: number,
params: PostParams,
): Promise<PostList> {
const response = await client.get<PostList>(
`${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}`,
`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}`,
{ params },
);
return response.data;
}
public async getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo> {
public async getRecentPostList(
params?: RecentPostParams,
): Promise<RecentPostList> {
const response = await client.get<RecentPostList>(
`${ENDPOINT.BOARD_SERVICE.POSTS}/recentPosts`,
{ params },
);
return response.data;
}
public async getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo> {
const response = await client.get<PostInfo>(
`${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`,
`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`,
);
return response.data;
}
@@ -34,34 +48,49 @@ export class PostApiService implements PostService {
public async createPost(data: PostFormData): Promise<Post> {
const validatedData = postFormSchema.parse(data);
const response = await client.post<Post>(
`${ENDPOINT.BOARD_SERVICE.POSTS_USER}`,
`${ENDPOINT.BOARD_SERVICE.POSTS}`,
validatedData,
);
return response.data;
}
public async updatePost(
bbsId: string,
pstNo: string,
bbsId: number,
pstNo: number,
data: PostUpdateFormData,
): Promise<Post> {
const validatedData = postUpdateFormSchema.parse(data);
const response = await client.put<Post>(
`${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`,
`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`,
validatedData,
);
return response.data;
}
public async deletePost(
bbsId: string,
pstNo: string,
pstPswd?: string,
bbsId: number,
pstNo: number,
delRsn?: string,
): Promise<void> {
await client.delete(
`${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`,
{ params: { pstPswd } },
);
await client.delete(`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, {
params: { delRsn },
});
}
public async softDeletePost(data: DeletePost[]): Promise<void> {
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/softDeleteSels`, data);
}
public async hardDeletePost(data: DeletePost[]): Promise<void> {
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/hardDeleteSels`, data);
}
public async movePost(data: MovePost[]): Promise<void> {
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/movePstByBbsId`, data);
}
public async restorePost(data: DeletePost[]): Promise<void> {
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/restoreSels`, data);
}
}
+5 -1
View File
@@ -3,9 +3,13 @@ export {
postFormSchema,
postUpdateFormSchema,
type Post,
type RecentPost,
type MovePost,
type DeletePost,
type PostList,
type PostParams,
type PostService,
type RecentPostParams,
type PostFormData,
type PostUpdateFormData,
type PostService,
} from "./model/post-interface";
@@ -8,13 +8,24 @@ export interface PostParams extends PageableParams {
upndFixYn?: string;
prdPstgYn?: string;
siteId?: string;
bbsTypeCd?: string;
}
export interface RecentPostParams extends PageableParams {
keywordType?: string;
keyword?: string;
bbsId?: number | null;
delYn?: string;
frDateYmd?: string;
toDateYmd?: string;
}
export interface Post {
bbsId: number;
rowNo: number;
bbsId: number;
pstNo: number;
pstTtl: string;
pstCn: string;
inqNocs: number;
rfrncUrl: string;
popupYn: string;
@@ -31,6 +42,11 @@ export interface Post {
cmntCnt: number;
}
export interface RecentPost extends Post {
bbsNm: string;
bbsTypeCd: string;
}
export interface PostInfo extends Post {
pstCn: string;
rgtr: string;
@@ -38,6 +54,18 @@ export interface PostInfo extends Post {
mdfr: string;
}
export interface MovePost {
fromBbsId: number;
toBbsId?: number;
pstNo: number;
}
export interface DeletePost {
bbsId: number;
pstNo: number;
delRsn?: string;
}
const postEtcSchema = z.object({
bbsPstgTypeCd: z.string(),
pstgPrdSeCd: z.string(),
@@ -69,17 +97,22 @@ export const postUpdateFormSchema = postFormSchema.omit({
});
export type PostList = PageableList<Post>;
export type RecentPostList = PageableList<RecentPost>;
export type PostFormData = z.infer<typeof postFormSchema>;
export type PostUpdateFormData = z.infer<typeof postUpdateFormSchema>;
export interface PostService {
getPostList(bbsId: string, params: PostParams): Promise<PostList>;
getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo>;
getPostList(bbsId: number, params: PostParams): Promise<PostList>;
getRecentPostList(params: RecentPostParams): Promise<RecentPostList>;
getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo>;
createPost(data: PostFormData): Promise<Post>;
updatePost(
bbsId: string,
pstNo: string,
bbsId: number,
pstNo: number,
data: PostUpdateFormData,
): Promise<Post>;
deletePost(bbsId: string, pstNo: string, pstPswd?: string): Promise<void>;
deletePost(bbsId: number, pstNo: number, delRsn?: string): Promise<void>;
softDeletePost(data: DeletePost[]): Promise<void>;
hardDeletePost(data: DeletePost[]): Promise<void>;
movePost(data: MovePost[]): Promise<void>;
}
@@ -287,7 +287,7 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) {
{parseInt(rememberPeriodCode[site.siteId] ?? "") ===
0 && (
<DateRangePicker
handleDateChange={(date) =>
onValueChange={(date) =>
handlePeriodDateChange(site.siteId)(
date as DateRange | undefined,
)
+3
View File
@@ -0,0 +1,3 @@
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
export { FaqPage } from "./ui/faq-page";
@@ -0,0 +1,43 @@
"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 { PostForm } from "@/widgets/post";
import { PostApi, postFormSchema, type PostFormData } from "@/entities/post";
import { useAlertStore, useUserStore } from "@/shared/lib";
function CreatePage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const { userInfo } = useUserStore((state) => state);
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createPost } = useMutation({
mutationFn: (data: PostFormData) => PostApi.createPost(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<PostFormData>({
resolver: zodResolver(postFormSchema),
values: {
bbsId: parseInt(bbsId),
pstTtl: "",
wrtrNm: userInfo?.userName ?? "",
pstPswd: userInfo?.userUnqId ?? "",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">FAQ 게시글 등록</h3>
</div>
<PostForm form={form} handleSave={createPost} />
</div>
);
}
export { CreatePage };
@@ -0,0 +1,317 @@
"use client";
import dayjs from "dayjs";
import React, { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Checkbox,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
} from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import { PostApi, type PostParams } from "@/entities/post";
import { useAlertStore, useMenuStore, usePrompt } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
function FaqPage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const pathname = usePathname();
const { prompt } = usePrompt();
const { setMessage: alert } = useAlertStore((state) => state);
const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state);
const [postParams, setPostParams] = useState<PostParams>({
keywordType: "title",
keyword: "",
});
const [selectedPostNumbers, setSelectedPostNumbers] = useState<number[]>([]);
const {
data: postList,
isFetched,
refetch,
} = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsInfo?.bbsTypeCd],
queryFn: () =>
PostApi.getPostList(parseInt(bbsId), {
...postParams,
bbsTypeCd: bbsInfo?.bbsTypeCd,
}),
enabled: !!bbsInfo?.bbsTypeCd,
});
const { mutate: deletePost } = useMutation({
mutationFn: ({ pstNo, delRsn }: { pstNo: number; delRsn?: string }) =>
PostApi.deletePost(parseInt(bbsId), pstNo, delRsn),
onSuccess: () => {
alert("삭제되었습니다.");
refetch();
},
});
const getPromptValue = async (message: string) => {
const { resolved, value } = await prompt(message);
if (!resolved) return "";
return value;
};
const handleDeletePost = async (pstNo: number) => {
const delRsn = await getPromptValue("삭제 사유를 입력해주세요.");
deletePost({ pstNo, delRsn });
};
const onSelectedPostDelete = async () => {
const delRsn = await getPromptValue("삭제 사유를 입력해주세요.");
await Promise.all(
selectedPostNumbers.map((pstNo) =>
PostApi.deletePost(parseInt(bbsId), pstNo, delRsn),
),
);
refetch();
setSelectedPostNumbers([]);
alert("삭제되었습니다.");
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
const handleCheckboxChange = (pstNo: number, checked: boolean | string) => {
setSelectedPostNumbers(
checked === true
? [...selectedPostNumbers, pstNo]
: selectedPostNumbers.filter((num) => num !== pstNo),
);
};
const handleAllCheckboxChange = (checked: boolean | string) => {
setSelectedPostNumbers(
checked === true ? postList?.content.map((item) => item.pstNo) || [] : [],
);
};
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 className="mr-2 text-label">검색</Label>
<Select
value={postParams.keywordType}
onValueChange={(keywordType) => {
setPostParams({ ...postParams, keywordType });
}}
>
<SelectTrigger className="h-[3.2rem] w-[10rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="title">제목</SelectItem>
<SelectItem value="all">제목+내용</SelectItem>
<SelectItem value="writer">작성자</SelectItem>
</SelectContent>
</Select>
<Input
value={postParams.keyword}
onChange={(e) =>
setPostParams({ ...postParams, keyword: e.target.value })
}
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
/>
<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">
{pageMenuAuthority?.regAuthrtYn === "Y" && (
<Button onClick={() => router.push(`${pathname}/register`)}>
등록
</Button>
)}
{pageMenuAuthority?.delAuthrtYn === "Y" && (
<Button
color="red"
onClick={onSelectedPostDelete}
disabled={!selectedPostNumbers.length}
>
선택삭제
</Button>
)}
</div>
</div>
</div>
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
<div className="mb-4 flex items-center gap-2 rounded-sm border border-border bg-background p-4">
<Checkbox
id="all-select"
checked={selectedPostNumbers.length === postList?.content.length}
onClick={(e) => e.stopPropagation()}
onCheckedChange={handleAllCheckboxChange}
/>
<Label htmlFor="all-select" className="text-2xl">
전체선택
</Label>
</div>
{isFetched ? (
<Accordion
type="multiple"
className="secondary flex flex-col gap-4"
>
{postList?.content.map((item) => (
<div key={item.pstNo} className="flex items-start gap-2">
<Checkbox
id={item.pstNo.toString()}
checked={selectedPostNumbers.includes(item.pstNo)}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
handleCheckboxChange(item.pstNo, checked)
}
className="mx-2 my-6"
/>
<AccordionItem
value={item.pstNo.toString()}
className="w-full"
>
<AccordionTrigger>
<div className="flex flex-1 items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-primary">Q</span>
<span>{item.pstTtl}</span>
</div>
<span className="px-4 text-[1.2rem] font-normal text-label">
등록일: {dayjs(item.regDt).format("YYYY-MM-DD")}
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="flex items-start gap-4">
<span className="text-[1.4rem] font-bold text-label">
A
</span>
<div className="flex-1">
<pre className="whitespace-pre-wrap font-noto text-[1.4rem] tracking-tighter text-foreground">
{item.pstCn}
</pre>
<Attachment atflCd={item.atflCd} />
</div>
<div className="flex justify-end gap-2">
<Button
size="icon"
color="white"
onClick={() =>
router.push(`${pathname}/${item.pstNo}`)
}
>
<i className="diveicon di-pencil" />
</Button>
<Button
size="icon"
color="white"
onClick={() => handleDeletePost(item.pstNo)}
>
<i className="diveicon di-trash" />
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</div>
))}
</Accordion>
) : (
<Skeleton className="h-[36rem] w-full" />
)}
</div>
</div>
</div>
);
}
function Attachment({ atflCd }: { atflCd: string }) {
const [filename, setFilename] = useState("");
const { data: downloadResponse, isFetching } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, atflCd],
queryFn: () => AttachmentApi.downloadAttachmentByAtflCd(atflCd),
enabled: !!atflCd,
});
const handleDownload = async () => {
if (!downloadResponse) return;
try {
const blob = new Blob([downloadResponse.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename || "download";
document.body.appendChild(link);
link.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
} catch (error) {
console.error(error);
}
};
useEffect(() => {
if (!downloadResponse) return;
try {
let filename = "";
const contentDisposition =
downloadResponse.headers["content-disposition"];
if (contentDisposition) {
const filenameMatch = contentDisposition.match(
/filename\*=UTF-8''([^;]+)/,
);
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1]);
}
}
setFilename(filename);
} catch {
setFilename("");
}
}, [downloadResponse]);
return isFetching ? (
<div className="my-8">
<Skeleton className="h-[2.5rem] w-[32rem]" />
</div>
) : (
filename && (
<div className="my-8 flex items-center gap-4">
<span className="font-bold text-label">첨부파일</span>
<Button
size="sm"
color="white"
className="flex w-fit items-center gap-2"
onClick={handleDownload}
>
<i className="diveicon di-download" />
<span>{filename}</span>
</Button>
</div>
)
);
}
export { FaqPage };
@@ -0,0 +1,53 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { PostForm } from "@/widgets/post";
import {
PostApi,
postUpdateFormSchema,
type PostUpdateFormData,
} from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
});
const form = useForm<PostUpdateFormData>({
resolver: zodResolver(postUpdateFormSchema),
values: {
...postInfo,
pstTtl: postInfo?.pstTtl ?? "",
pstPswd: userInfo?.userUnqId ?? "",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">FAQ 게시글 수정</h3>
</div>
<PostForm form={form} handleSave={updatePost} />
</div>
);
}
export { ModifyPage };
@@ -19,13 +19,13 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const { userInfo } = useUserStore((state) => state);
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(bbsId, pstNo, data),
PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
@@ -19,13 +19,13 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const { userInfo } = useUserStore((state) => state);
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(bbsId, pstNo, data),
PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
@@ -19,13 +19,13 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const { userInfo } = useUserStore((state) => state);
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(bbsId, pstNo, data),
PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
@@ -19,13 +19,13 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const { userInfo } = useUserStore((state) => state);
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(bbsId, pstNo, data),
PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import dayjs from "dayjs";
import { usePathname, useRouter } from "next/navigation";
import type { ColumnDef, Row } from "@tanstack/react-table";
@@ -61,6 +62,7 @@ const ManageButton = ({ row }: { row: Row<Board> }) => {
onClick={(e) => {
e.stopPropagation();
router.push(`${pathname}/${bbsTypeCd.toLowerCase()}/${bbsId}`);
// router.push(`/pst/${bbsTypeCd.toLowerCase()}/${bbsId}`); // 공통 개발용
setBbsInfo(JSON.stringify(row.original));
setStoreBbsInfo(row.original);
}}
+1
View File
@@ -0,0 +1 @@
export { RecentPage } from "./ui/recent-page";
@@ -0,0 +1,117 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Button, Checkbox } from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import type { RecentPost } from "@/entities/post";
export const columns: ColumnDef<RecentPost>[] = [
{
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"
/>
),
meta: {
width: 40,
},
},
{
accessorKey: "rowNo",
header: "NO",
meta: {
width: 80,
},
},
{
accessorKey: "bbsNm",
header: "게시판",
meta: {
width: 200,
},
},
{
accessorKey: "pstTtl",
header: "제목",
cell: ({ row }) => <div className="text-left">{row.original.pstTtl}</div>,
},
{
accessorKey: "atflCd",
header: "첨부파일",
cell: ({ row }) => {
const atflCd = row.original.atflCd;
if (!atflCd) return null;
return <AttachmentButton atflCd={atflCd} />;
},
meta: {
width: 100,
},
},
{
accessorKey: "rgtrNm",
header: "작성자",
meta: {
width: 120,
},
},
{
accessorKey: "regDt",
header: "등록일",
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"),
meta: {
width: 120,
},
},
];
const AttachmentButton = ({ atflCd }: { atflCd: string }) => {
const handleDownload = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
try {
let filename = "";
const response = await AttachmentApi.downloadAttachmentByAtflCd(atflCd);
const contentDisposition = response.headers["content-disposition"];
if (contentDisposition) {
const filenameMatch = contentDisposition.match(
/filename\*=UTF-8''([^;]+)/,
);
if (filenameMatch) {
filename = decodeURIComponent(filenameMatch[1]);
}
}
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
} catch (error) {
console.error(error);
}
};
return (
<Button size="icon" color="white" onClick={handleDownload}>
<i className="diveicon di-download" />
</Button>
);
};
@@ -0,0 +1,68 @@
import React, { useRef } from "react";
import {
Button,
Dialog,
DialogContent,
DialogTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import type { Board } from "@/entities/board";
import type { MovePost } from "@/entities/post";
type MovePostDialogProps = {
selectedPosts: MovePost[];
boardList: Board[];
onMovePost: (data: MovePost[]) => void;
};
function MovePostDialog({
selectedPosts,
boardList,
onMovePost,
}: MovePostDialogProps) {
const selectedPostsRef = useRef<MovePost[]>(selectedPosts);
const onSelectValueChange = (toBbsId: string) => {
selectedPostsRef.current = selectedPosts.map((post) => ({
...post,
toBbsId: parseInt(toBbsId),
}));
};
return (
<Dialog>
<DialogTrigger asChild>
<Button color="emerald" disabled={!selectedPosts.length}>
게시물 이동
</Button>
</DialogTrigger>
<DialogContent>
<p className="pt-8 text-center text-[1.3rem]">
이동할 게시판을 선택해 주세요.
</p>
<Select onValueChange={onSelectValueChange}>
<SelectTrigger className="my-4">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{boardList.map((board) => (
<SelectItem key={board.bbsId} value={board.bbsId.toString()}>
{board.bbsNm}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => onMovePost(selectedPostsRef.current)}>
이동
</Button>
</DialogContent>
</Dialog>
);
}
export { MovePostDialog };
+342
View File
@@ -0,0 +1,342 @@
"use client";
import dayjs from "dayjs";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import {
Button,
DateRangePicker,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
DataTable,
} from "@common/components";
import {
PostApi,
type DeletePost,
type MovePost,
type RecentPost,
type RecentPostParams,
} from "@/entities/post";
import { useAlertStore, useMenuStore, usePrompt } from "@/shared/lib";
import { BoardApi } from "@/entities/board";
import { ENDPOINT } from "@/shared/config";
import { columns } from "../model/table-columns";
import { MovePostDialog } from "./move-post-dialog";
type DateRange = {
from: Date | undefined;
to?: Date | undefined;
};
function RecentPage() {
const router = useRouter();
const { prompt } = usePrompt();
const { confirm } = useConfirm();
const { setMessage: alert } = useAlertStore((state) => state);
const { pageMenuAuthority } = useMenuStore((state) => state);
const [dateRange, setDateRange] = useState<DateRange>();
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [recentPostParams, setRecentPostParams] = useState<RecentPostParams>({
keywordType: "",
keyword: "",
bbsId: null,
delYn: "N",
frDateYmd: "",
toDateYmd: "",
});
const [isDeletePostData, setIsDeletePostData] = useState(false);
const { data: recentPostList, refetch } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, pagination],
queryFn: () =>
PostApi.getRecentPostList({
...recentPostParams,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
});
const { data: boardList } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.BBS],
queryFn: () => BoardApi.getBoardList(),
});
const { mutate: movePost } = useMutation({
mutationFn: (data: MovePost[]) => PostApi.movePost(data),
onSuccess: () => {
alert("이동이 완료되었습니다.", refetch);
},
});
const table = useReactTable({
data: recentPostList?.content ?? [],
columns,
state: {
pagination,
rowSelection,
},
rowCount: recentPostList?.totalElements ?? 0,
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
});
const isSelectedRow = table.getFilteredSelectedRowModel().rows.length > 0;
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsDeletePostData(recentPostParams.delYn === "Y");
refetch();
};
const handleRowClick = (row: RecentPost) => {
router.push(
`/bbs/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}`,
);
};
const handleRecentPostParamsChange =
(key: keyof RecentPostParams) => (value: unknown) => {
setRecentPostParams({ ...recentPostParams, [key]: value });
};
const handlePresetDateRange = (value: string) => {
let presetDateRange: DateRange | undefined;
switch (value) {
case "1W":
presetDateRange = {
from: dayjs().subtract(7, "day").toDate(),
to: dayjs().toDate(),
};
break;
case "1M":
presetDateRange = {
from: dayjs().subtract(30, "day").toDate(),
to: dayjs().toDate(),
};
break;
case "3M":
presetDateRange = {
from: dayjs().subtract(90, "day").toDate(),
to: dayjs().toDate(),
};
break;
default:
presetDateRange = undefined;
}
setDateRange(presetDateRange);
};
const onSoftDeletePost = async () => {
const { resolved, value: delRsn } =
await prompt("삭제 사유를 입력해주세요.");
if (!resolved) return;
const selectedPosts: DeletePost[] = table
.getFilteredSelectedRowModel()
.rows.map((row) => ({
bbsId: row.original.bbsId,
pstNo: row.original.pstNo,
delRsn,
}));
PostApi.softDeletePost(selectedPosts);
alert("삭제가 완료되었습니다.", refetch);
setRowSelection({});
};
const onHardDeletePost = async () => {
if (
await confirm(
"게시물을 완전 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.",
)
) {
const selectedPosts: DeletePost[] = table
.getFilteredSelectedRowModel()
.rows.map((row) => ({
bbsId: row.original.bbsId,
pstNo: row.original.pstNo,
}));
PostApi.hardDeletePost(selectedPosts);
alert("삭제가 완료되었습니다.", refetch);
setRowSelection({});
}
};
const onRestorePost = async () => {
if (await confirm("게시물을 복원하시겠습니까?")) {
const selectedPosts: DeletePost[] = table
.getFilteredSelectedRowModel()
.rows.map((row) => ({
bbsId: row.original.bbsId,
pstNo: row.original.pstNo,
}));
PostApi.restorePost(selectedPosts);
alert("복원이 완료되었습니다.", refetch);
setRowSelection({});
}
};
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 className="mr-2">날짜</Label>
<DateRangePicker value={dateRange} onValueChange={setDateRange}>
<div className="card card-border flex items-center gap-2">
<Select onValueChange={handlePresetDateRange}>
<SelectTrigger>
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1W">일주일</SelectItem>
<SelectItem value="1M">1개월</SelectItem>
<SelectItem value="3M">3개월</SelectItem>
</SelectContent>
</Select>
</div>
</DateRangePicker>
<Label className="ml-4 mr-2 text-label">게시판</Label>
<Select onValueChange={handleRecentPostParamsChange("bbsId")}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{boardList?.content.map((board) => (
<SelectItem key={board.bbsId} value={board.bbsId.toString()}>
{board.bbsNm}
</SelectItem>
))}
</SelectContent>
</Select>
<Label className="ml-4 mr-2 text-label">상태</Label>
<Select onValueChange={handleRecentPostParamsChange("delYn")}>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="N">정상</SelectItem>
<SelectItem value="Y">삭제</SelectItem>
</SelectContent>
</Select>
<Label className="ml-4 mr-2 text-label">검색</Label>
<Select
value={recentPostParams.keywordType}
onValueChange={handleRecentPostParamsChange("keywordType")}
>
<SelectTrigger className="h-[3.2rem] w-[10rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="title">제목</SelectItem>
<SelectItem value="all">제목+내용</SelectItem>
<SelectItem value="writer">작성자</SelectItem>
</SelectContent>
</Select>
<Input
value={recentPostParams.keyword}
onChange={(e) =>
handleRecentPostParamsChange("keyword")(e.target.value)
}
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
/>
<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">
{pageMenuAuthority?.mngAuthrtYn === "Y" &&
(isDeletePostData ? (
<>
<Button
color="emerald"
onClick={onRestorePost}
disabled={!isSelectedRow}
>
게시물 복원
</Button>
<Button
color="red"
onClick={onHardDeletePost}
disabled={!isSelectedRow}
>
완전삭제
</Button>
</>
) : (
<>
<MovePostDialog
selectedPosts={table
.getFilteredSelectedRowModel()
.rows.map((row) => ({
fromBbsId: row.original.bbsId,
pstNo: row.original.pstNo,
}))}
boardList={boardList?.content ?? []}
onMovePost={movePost}
/>
<Button
color="red"
onClick={onSoftDeletePost}
disabled={!isSelectedRow}
>
선택삭제
</Button>
</>
))}
<Select
value={table.getState().pagination.pageSize.toString()}
onValueChange={(value) => {
table.setPageSize(parseInt(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 { RecentPage };
+1 -1
View File
@@ -42,7 +42,7 @@ export const ENDPOINT = {
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
POSTS_USER: "/board-service/api/v1/posts/user",
POSTS: "/board-service/api/v1/posts",
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
},
};
+3 -3
View File
@@ -7,18 +7,18 @@ export const usePrompt = () => {
const prompt = (message: string) => {
setMessage(message);
return new Promise<string>((resolve) => {
return new Promise<{ resolved: boolean; value?: string }>((resolve) => {
setResolve(() => resolve);
});
};
const onSubmit = (value: string) => {
resolve?.(value);
resolve?.({ resolved: true, value });
setMessage("");
};
const onCancel = () => {
resolve?.("");
resolve?.({ resolved: false });
setMessage("");
};
+1
View File
@@ -1 +1,2 @@
export { StoreProvider } from "./store-provider";
export { PromptProvider } from "./prompt-provider";
@@ -2,9 +2,15 @@ import { createContext, useState } from "react";
interface PromptContext {
message?: string;
resolve?: (value: string) => void;
resolve?: ({
resolved,
value,
}: {
resolved: boolean;
value?: string;
}) => void;
setMessage: (message: string) => void;
setResolve: (resolve: (value: string) => void) => void;
setResolve: (resolve: () => void) => void;
}
export const PromptContext = createContext<PromptContext>({
@@ -16,7 +22,10 @@ export const PromptContext = createContext<PromptContext>({
export const PromptProvider = ({ children }: { children: React.ReactNode }) => {
const [message, setMessage] = useState<string>();
const [resolve, setResolve] = useState<(value: string) => void>();
const [resolve, setResolve] =
useState<
({ resolved, value }: { resolved: boolean; value?: string }) => void
>();
return (
<PromptContext.Provider
@@ -56,7 +56,7 @@ function GridViewDataTable<T>({
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={cn(
"rounded-md border border-border bg-background p-4 shadow-md",
"overflow-hidden rounded-md border border-border bg-background p-4 shadow-md",
onRowClick && "cursor-pointer hover:bg-accent",
)}
>
+45
View File
@@ -0,0 +1,45 @@
import { useState } from "react";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogClose,
Input,
} from "@common/components";
import { usePrompt } from "@/shared/lib/use-prompt";
function PromptDialog() {
const { message, isOpen, onSubmit, onCancel } = usePrompt();
const [value, setValue] = useState("");
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">
{message}
</p>
<Input
type="text"
value={value}
className="!text-[1.3rem]"
onChange={(e) => setValue(e.target.value)}
/>
</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 { PromptDialog };
@@ -1,12 +1,19 @@
import dayjs from "dayjs";
import Image from "next/image";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge, Checkbox, Skeleton } from "@common/components";
import { Badge, Checkbox, Skeleton, cn } from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import type { Post } from "@/entities/post";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { useMenuStore } from "@/shared/lib";
import type { BbsType } from "@/shared/store/menu-store";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData> {
bbsTypeCd?: BbsType;
}
}
export const createColumns = (
thumbRefs: React.MutableRefObject<Map<number, string>>,
@@ -23,73 +30,121 @@ export const createColumns = (
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => {
const { pstTtl, inqNocs } = row.original;
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
cell: ({ row, table }) => {
const { atflCd, pstNo, pstTtl, pstCn, inqNocs, rgtrNm, regDt } =
row.original;
const bbsTypeCd = table.options.meta?.bbsTypeCd;
if (bbsTypeCd === "BBSIMAGE") {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
{inqNocs >= 100 && <Badge color="red">HOT</Badge>}
{dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && (
<Badge color="blue">NEW</Badge>
)}
<span className="text-[1.6rem] font-bold text-label">
{pstTtl}
</span>
</div>
<ThumbImage
atflCd={atflCd}
pstNo={pstNo}
thumbRefs={thumbRefs}
bbsTypeCd={bbsTypeCd}
/>
{inqNocs >= 100 && <Badge color="red">HOT</Badge>}
{dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && (
<Badge color="blue">NEW</Badge>
)}
<div className="flex flex-col">
<span className="text-[1.4rem] font-bold text-label">
{rgtrNm}
</span>
<span className="text-[1.2rem] text-label">
{dayjs(regDt).format("YYYY-MM-DD")}
</span>
</div>
</div>
<span className="text-3xl font-bold leading-none text-label">
{pstTtl}
</span>
</div>
);
);
}
if (bbsTypeCd === "BBSMOVIE") {
return (
<div className="-m-4">
<ThumbImage
atflCd={atflCd}
pstNo={pstNo}
thumbRefs={thumbRefs}
bbsTypeCd={bbsTypeCd}
classes={{
image: "object-cover",
}}
/>
<div className="p-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
{inqNocs >= 100 && <Badge color="red">HOT</Badge>}
{dayjs().diff(dayjs(regDt), "hour") < 24 && (
<Badge color="blue">NEW</Badge>
)}
<span className="text-[1.6rem] font-bold text-label">
{pstTtl}
</span>
</div>
<p className="line-clamp-3 overflow-hidden text-[1.2rem] text-label">
{pstCn}
</p>
<div className="flex flex-col">
<span className="text-[1.4rem] font-bold text-label">
{rgtrNm}
</span>
<span className="text-[1.2rem] text-label">
{dayjs(regDt).format("YYYY-MM-DD")}
</span>
</div>
</div>
</div>
</div>
);
}
},
},
{
id: "atflCd",
cell: ({ row }) => {
const { atflCd, pstNo } = row.original;
return <ThumbImage atflCd={atflCd} pstNo={pstNo} thumbRefs={thumbRefs} />;
},
},
{
id: "rgtrNm",
cell: ({ row }) => {
return (
<span className="text-[1.4rem] font-bold text-label">
{row.original.rgtrNm}
</span>
);
},
},
{
id: "regDt",
cell: ({ row }) => (
<span className="text-[1.2rem] text-label">
{dayjs(row.original.regDt).format("YYYY-MM-DD")}
</span>
),
},
];
type ThumbImageProps = {
atflCd: string;
pstNo: number;
thumbRefs: React.MutableRefObject<Map<number, string>>;
bbsTypeCd?: string;
classes?: {
image?: string;
};
};
const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => {
const ThumbImage = ({
atflCd,
pstNo,
thumbRefs,
bbsTypeCd,
classes,
}: ThumbImageProps) => {
const prevImage = thumbRefs.current.get(pstNo);
const { bbsInfo } = useMenuStore((state) => state);
const { data: imageUrl, isLoading } = useQuery({
const { data, isLoading } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd],
queryFn: () => {
if (bbsInfo?.bbsTypeCd === "BBSIMAGE") {
if (bbsTypeCd === "BBSIMAGE") {
return AttachmentApi.getImageUrlByAtflCd(atflCd);
} else if (bbsInfo?.bbsTypeCd === "BBSMOVIE") {
} else if (bbsTypeCd === "BBSMOVIE") {
return AttachmentApi.getMovieThumbnailUrlByAtflCd(atflCd);
}
},
@@ -100,19 +155,21 @@ const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => {
enabled: !prevImage && !!atflCd,
});
const imageUrl = prevImage ?? data;
if (isLoading)
return (
<div className="my-6 w-full">
<Skeleton className="mx-auto h-[16rem] w-full" />
<div className="h-[16rem] w-full">
<Skeleton className="h-full w-full" />
</div>
);
if (imageUrl)
return (
<div className="relative my-6 flex h-[16rem] w-full items-center justify-center">
<div className="relative flex h-[16rem] w-full items-center justify-center">
<Image
src={imageUrl}
className="object-contain"
className={cn("object-contain", classes?.image)}
fill
alt="썸네일 이미지"
/>
@@ -120,7 +177,7 @@ const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => {
);
return (
<div className="my-6 flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background">
<div className="flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background">
<i className="diveicon di-image text-3xl" />
</div>
);
@@ -34,7 +34,7 @@ export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [
return (
<Badge
color="orange"
className="flex size-10 items-center justify-center"
className="flex size-8 items-center justify-center"
>
<i className="diveicon di-volume-up-fill" />
</Badge>
+7 -7
View File
@@ -1,24 +1,24 @@
import { useRouter, usePathname } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button } from "@common/components";
import { useMenuStore, useUserStore } from "@/shared/lib";
import { useMenuStore, usePrompt, useUserStore } from "@/shared/lib";
type PostActionProps = {
bbsId: string;
pstNo: string;
children: React.ReactNode;
handleDelete: () => Promise<void>;
handleDelete: (delRsn?: string) => Promise<void>;
};
function PostAction({ children, handleDelete }: PostActionProps) {
const router = useRouter();
const pathname = usePathname();
const { confirm } = useConfirm();
const { prompt } = usePrompt();
const { bbsInfo } = useMenuStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const onDelete = async () => {
if (await confirm("삭제하시겠습니까?")) handleDelete();
const { resolved, value } = await prompt("삭제 사유를 입력해주세요.");
if (resolved) handleDelete(value);
};
const checkAnsUseAuthrt = () => {
@@ -35,7 +35,7 @@ function PostAction({ children, handleDelete }: PostActionProps) {
};
return (
<div>
<>
{children}
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
@@ -68,7 +68,7 @@ function PostAction({ children, handleDelete }: PostActionProps) {
삭제
</Button>
</div>
</div>
</>
);
}
+2 -1
View File
@@ -89,7 +89,8 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) {
if (userInfo?.userUnqId) {
cmntPswd = userInfo?.userUnqId;
} else {
cmntPswd = await prompt("비밀번호를 입력해주세요.");
const { resolved, value } = await prompt("비밀번호를 입력해주세요.");
if (resolved) cmntPswd = value ?? "";
}
return cmntPswd;
};
+20 -23
View File
@@ -1,8 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
@@ -24,7 +23,7 @@ import {
} from "@common/components";
import { PostApi, type Post, type PostParams } from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useMenuStore, useUserStore } from "@/shared/lib";
import { useMenuStore, usePrompt } 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";
@@ -32,8 +31,7 @@ import { createColumns as createGridViewColumns } from "../model/grid-view-table
function PostPage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const pathname = usePathname();
const { confirm } = useConfirm();
const { userInfo } = useUserStore((state) => state);
const { prompt } = usePrompt();
const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state);
const thumbRefs = useRef<Map<number, string>>(new Map());
@@ -48,9 +46,9 @@ function PostPage({ bbsId }: { bbsId: string }) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { data: upndFixPostList } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, bbsInfo],
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, bbsInfo],
queryFn: () =>
PostApi.getPostList(bbsId, {
PostApi.getPostList(parseInt(bbsId), {
...postParams,
upndFixYn: bbsInfo?.upndFixUseYn,
}),
@@ -69,14 +67,15 @@ function PostPage({ bbsId }: { bbsId: string }) {
isFetched,
refetch,
} = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId],
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsInfo?.bbsTypeCd],
queryFn: () =>
PostApi.getPostList(bbsId, {
PostApi.getPostList(parseInt(bbsId), {
...postParams,
bbsTypeCd: bbsInfo?.bbsTypeCd,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
enabled: false,
enabled: !!bbsInfo?.bbsTypeCd,
});
const isGridViewPage =
@@ -94,6 +93,7 @@ function PostPage({ bbsId }: { bbsId: string }) {
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
meta: { bbsTypeCd: bbsInfo?.bbsTypeCd },
});
const DataTableComponent = isGridViewPage ? GridViewDataTable : DataTable;
@@ -103,15 +103,16 @@ function PostPage({ bbsId }: { bbsId: string }) {
.getFilteredSelectedRowModel()
.rows.map((row) => row.original.pstNo);
if (await confirm("삭제하시겠습니까?")) {
setRowSelection({});
await Promise.all(
selectedPosts.map((pstNo) =>
PostApi.deletePost(bbsId, pstNo.toString(), userInfo?.userUnqId),
),
);
refetch();
}
const { resolved, value: delRsn } =
await prompt("삭제 사유를 입력해주세요.");
if (!resolved) return;
setRowSelection({});
await Promise.all(
selectedPosts.map((pstNo) =>
PostApi.deletePost(parseInt(bbsId), pstNo, delRsn),
),
);
refetch();
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
@@ -123,10 +124,6 @@ function PostPage({ bbsId }: { bbsId: string }) {
router.push(`${pathname}/${row.pstNo}`);
};
useEffect(() => {
refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
+7 -23
View File
@@ -8,28 +8,21 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { Button, Skeleton } from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import { PostApi } from "@/entities/post";
import {
useAlertStore,
useUserStore,
useMenuStore,
usePrompt,
} from "@/shared/lib";
import { useAlertStore, useMenuStore } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { PostAction } from "./post-action";
import { PostComment } from "./post-comment";
function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const router = useRouter();
const { prompt } = usePrompt();
const { setMessage: alert } = useAlertStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const { bbsInfo } = useMenuStore((state) => state);
const [filename, setFilename] = useState("");
const { data: postData } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
});
const { data: downloadResponse, isFetching } = useQuery({
@@ -65,23 +58,14 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
enabled: !!videoAttachment?.atflId,
});
const { mutate: deletePost } = useMutation({
mutationFn: (pstPswd: string) => PostApi.deletePost(bbsId, pstNo, pstPswd),
const { mutateAsync: deletePost } = useMutation({
mutationFn: (delRsn?: string) =>
PostApi.deletePost(parseInt(bbsId), parseInt(pstNo), delRsn),
onSuccess: () => {
alert("삭제되었습니다.", router.back);
},
});
const handleDelete = async () => {
let pstPswd = "";
if (userInfo?.userUnqId) {
pstPswd = userInfo?.userUnqId;
} else {
pstPswd = await prompt("비밀번호를 입력해주세요.");
}
deletePost(pstPswd);
};
const handleDownload = async () => {
if (!downloadResponse) return;
try {
@@ -123,7 +107,7 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
return (
<>
<PostAction bbsId={bbsId} pstNo={pstNo} handleDelete={handleDelete}>
<PostAction bbsId={bbsId} pstNo={pstNo} handleDelete={deletePost}>
<div className="card card-border !p-8">
<h3 className="text-[2rem] font-bold text-foreground">
{postData?.pstTtl}
@@ -76,9 +76,7 @@ function PostingPeriodSelector({
</span>
)
)}
{periodCode === "0" && (
<DateRangePicker handleDateChange={onDateChange} />
)}
{periodCode === "0" && <DateRangePicker onValueChange={onDateChange} />}
</div>
);
}
@@ -2,11 +2,11 @@ import "./button-group.css";
import type { Meta, StoryObj } from "@storybook/react";
import { RadioGroup, RadioGroupItem } from "../radio-group";
import { ButtonGroup, ButtonGroupItem } from "../button-group";
const meta: Meta<typeof RadioGroup> = {
const meta: Meta<typeof ButtonGroup> = {
title: "Elements/ButtonGroup",
component: RadioGroup,
component: ButtonGroup,
parameters: {
docs: {
description: {
@@ -19,49 +19,49 @@ ButtonGroup 컴포넌트는 RadioGroup 컴포넌트를 기반으로 버튼 모
};
export default meta;
type Story = StoryObj<typeof RadioGroup>;
type Story = StoryObj<typeof ButtonGroup>;
export const Default: Story = {
render: () => (
<RadioGroup defaultValue="comfortable" variant="button">
<RadioGroupItem value="default">Default</RadioGroupItem>
<RadioGroupItem value="comfortable">Comfortable</RadioGroupItem>
<RadioGroupItem value="compact">Compact</RadioGroupItem>
</RadioGroup>
<ButtonGroup defaultValue="comfortable" variant="button">
<ButtonGroupItem value="default">Default</ButtonGroupItem>
<ButtonGroupItem value="comfortable">Comfortable</ButtonGroupItem>
<ButtonGroupItem value="compact">Compact</ButtonGroupItem>
</ButtonGroup>
),
};
export const Secondary: Story = {
render: () => (
<RadioGroup defaultValue="comfortable" variant="button" color="secondary">
<RadioGroupItem value="default">Default</RadioGroupItem>
<RadioGroupItem value="comfortable">Comfortable</RadioGroupItem>
<RadioGroupItem value="compact">Compact</RadioGroupItem>
</RadioGroup>
<ButtonGroup defaultValue="comfortable" variant="button" color="secondary">
<ButtonGroupItem value="default">Default</ButtonGroupItem>
<ButtonGroupItem value="comfortable">Comfortable</ButtonGroupItem>
<ButtonGroupItem value="compact">Compact</ButtonGroupItem>
</ButtonGroup>
),
};
export const Rounded: Story = {
render: () => (
<RadioGroup defaultValue="comfortable" variant="button" shape="rounded">
<RadioGroupItem value="default">Default</RadioGroupItem>
<RadioGroupItem value="comfortable">Comfortable</RadioGroupItem>
<RadioGroupItem value="compact">Compact</RadioGroupItem>
</RadioGroup>
<ButtonGroup defaultValue="comfortable" variant="button" shape="rounded">
<ButtonGroupItem value="default">Default</ButtonGroupItem>
<ButtonGroupItem value="comfortable">Comfortable</ButtonGroupItem>
<ButtonGroupItem value="compact">Compact</ButtonGroupItem>
</ButtonGroup>
),
};
export const SecondaryRounded: Story = {
render: () => (
<RadioGroup
<ButtonGroup
defaultValue="comfortable"
variant="button"
color="secondary"
shape="rounded"
>
<RadioGroupItem value="default">Default</RadioGroupItem>
<RadioGroupItem value="comfortable">Comfortable</RadioGroupItem>
<RadioGroupItem value="compact">Compact</RadioGroupItem>
</RadioGroup>
<ButtonGroupItem value="default">Default</ButtonGroupItem>
<ButtonGroupItem value="comfortable">Comfortable</ButtonGroupItem>
<ButtonGroupItem value="compact">Compact</ButtonGroupItem>
</ButtonGroup>
),
};
@@ -0,0 +1,39 @@
import "./button-group.css";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
const ButtonGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> & {
variant?: "radio" | "button";
color?: "primary" | "secondary";
shape?: "square" | "rounded";
}
>(
(
{ variant = "radio", color = "primary", shape = "square", ...props },
ref,
) => {
return (
<RadioGroupPrimitive.Root
{...props}
ref={ref}
data-variant={variant}
data-color={color}
data-shape={shape}
/>
);
},
);
ButtonGroup.displayName = RadioGroupPrimitive.Root.displayName;
const ButtonGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ ...props }, ref) => {
return <RadioGroupPrimitive.Item ref={ref} {...props} />;
});
ButtonGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { ButtonGroup, ButtonGroupItem };
@@ -16,6 +16,7 @@ import type { HTMLAttributes } from "react";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
width?: number;
align?: "left" | "center" | "right";
rowSpan?: number;
}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import type { DateRange } from "react-day-picker";
@@ -11,20 +11,38 @@ import { Calendar } from "../calendar";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
type DateRangePickerProps = React.HTMLAttributes<HTMLDivElement> & {
value?: DateRange;
defaultValue?: DateRange;
disabled?: boolean;
handleDateChange?: (date: DateRange | undefined) => void;
onValueChange?: (date: DateRange | undefined) => void;
};
export function DateRangePicker({
value,
defaultValue,
disabled,
className,
handleDateChange,
children,
onValueChange,
}: DateRangePickerProps) {
const [date, setDate] = useState<DateRange>();
const [dateState, setDateState] = useState<DateRange | undefined>(
defaultValue,
);
const isControlled = value !== undefined;
const dateValue = isControlled ? value : dateState;
useEffect(() => {
if (isControlled) {
setDateState(value);
}
}, [isControlled, value]);
const handleSelect = (date: DateRange | undefined) => {
setDate(date);
handleDateChange?.(date);
if (!isControlled) {
setDateState(date);
}
onValueChange?.(date);
};
return (
@@ -36,19 +54,19 @@ export function DateRangePicker({
variant="outline"
className={cn(
"h-[3.2rem] min-w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent",
!date && "text-muted-foreground",
!dateValue && "text-muted-foreground",
)}
disabled={disabled}
>
<CalendarIcon className="!size-[1.6rem]" />
{date?.from ? (
date.to ? (
{dateValue?.from ? (
dateValue.to ? (
<>
{format(date.from, "yyyy-MM-dd")} ~{" "}
{format(date.to, "yyyy-MM-dd")}
{format(dateValue.from, "yyyy-MM-dd")} ~{" "}
{format(dateValue.to, "yyyy-MM-dd")}
</>
) : (
format(date.from, "yyyy-MM-dd")
format(dateValue.from, "yyyy-MM-dd")
)
) : (
<span>기간 선택</span>
@@ -56,14 +74,17 @@ export function DateRangePicker({
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={handleSelect}
numberOfMonths={2}
/>
<div>
{children}
<Calendar
initialFocus
mode="range"
defaultMonth={dateValue?.from}
selected={dateValue}
onSelect={handleSelect}
numberOfMonths={2}
/>
</div>
</PopoverContent>
</Popover>
</div>
@@ -1,6 +1,7 @@
export * from "./accordion";
export * from "./badge";
export * from "./button";
export * from "./button-group";
export * from "./calendar";
export * from "./carousel";
export * from "./checkbox";