fix: build 에러 수정, RealGrid 테스트 key 갱신

This commit is contained in:
2025-12-22 10:43:14 +09:00
parent 176d0ad089
commit df9cb0a10c
27 changed files with 18 additions and 2503 deletions
@@ -1,10 +0,0 @@
import { ViewPopup as VViewPopup } from "@/pages/popup";
export default async function ViewPopup({
params,
}: {
params: Promise<{ popupSn: string }>;
}) {
const { popupSn } = await params;
return <VViewPopup popupSn={popupSn} />;
}
@@ -1,10 +0,0 @@
import { ViewSurveyArticle } from "@/pages/survey";
export default async function ViewSurveyArticlePage({
params,
}: {
params: Promise<{ srvySn: string }>;
}) {
const { srvySn } = await params;
return <ViewSurveyArticle srvySn={srvySn} />;
}
@@ -1,10 +0,0 @@
import { ViewSurveyResult } from "@/pages/survey";
export default async function ViewSurveyResultPage({
params,
}: {
params: Promise<{ srvySn: string }>;
}) {
const { srvySn } = await params;
return <ViewSurveyResult srvySn={srvySn} />;
}
+1 -1
View File
@@ -22,7 +22,7 @@ if (typeof window !== "undefined") {
window.JSZip = JSZip;
}
RealGrid.setLicenseKey('upVcPE+wPOmtLjqyBIh9RkM/nBOseBrflwxYpzGZyYm9cY8amGDkiPSoO0A3bI0LS1+jeXPBr4vmDWGWN+mYwx6C1EI62/mceibHCCl3OrbDYCIvknJkCQqgn3X6yKqZI9h9wRtIvbtWMBMOPHCENQuxnxtVqI2reHIsW5fXYGA=');
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
const DataGrid = ({ columnInfo, rows = [], rowStartNum, isExcelImport = true, fixedColumns }: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
+2 -2
View File
@@ -24,8 +24,8 @@ const getSidoListFetcher = async (): Promise<ApiResponse<Sido[]>> => {
return statisticsAPi.getSidoList();
};
const getSggListFetcher = async ([_key, sidoName]: [string, string]): Promise<ApiResponse<Sgg[]>> => {
return statisticsAPi.getSggList(sidoName);
const getSggListFetcher = async (key: [string, string]): Promise<ApiResponse<Sgg[]>> => {
return statisticsAPi.getSggList(key[1]);
};
const SearchForm = (props: SearchFormProps = {}) => {
@@ -1,5 +1,3 @@
import { z } from "zod";
export interface Sido {
adstDstrCd: string;
sido: string;
+15 -14
View File
@@ -1,3 +1,4 @@
import type { ReactElement } from "react";
import { type UseQueryResult } from "@tanstack/react-query";
import type { PageableList } from "../model";
@@ -9,24 +10,24 @@ import type { PageableList } from "../model";
export function matchQueryStatus<T>(
query: UseQueryResult<PageableList<T>>,
options: {
Loading?: JSX.Element;
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
Empty?: JSX.Element;
Loading?: ReactElement;
Errored?: ReactElement | ((error: unknown) => ReactElement);
Empty?: ReactElement;
Success: (
data: UseQueryResult<PageableList<T>> & {
data: NonNullable<UseQueryResult<T>["data"]>;
},
) => JSX.Element;
) => ReactElement;
},
): JSX.Element;
): ReactElement;
export function matchQueryStatus<T>(
query: UseQueryResult<PageableList<T>>,
options: {
Loading?: JSX.Element;
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
Success: (data: UseQueryResult<PageableList<T>>) => JSX.Element;
Loading?: ReactElement;
Errored?: ReactElement | ((error: unknown) => ReactElement);
Success: (data: UseQueryResult<PageableList<T>>) => ReactElement;
},
): JSX.Element;
): ReactElement;
export function matchQueryStatus<T>(
query: UseQueryResult<PageableList<T>>,
{
@@ -35,12 +36,12 @@ export function matchQueryStatus<T>(
Empty,
Success,
}: {
Loading?: JSX.Element;
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
Empty?: JSX.Element;
Success: (data: UseQueryResult<PageableList<T>>) => JSX.Element;
Loading?: ReactElement;
Errored?: ReactElement | ((error: unknown) => ReactElement);
Empty?: ReactElement;
Success: (data: UseQueryResult<PageableList<T>>) => ReactElement;
},
): JSX.Element {
): ReactElement {
if (query.isLoading && Loading) {
return Loading;
}
-2
View File
@@ -1,2 +0,0 @@
export { DndTree } from "./ui/dnd-tree";
export { EditableNode } from "./ui/editable-node";
@@ -1,105 +0,0 @@
import { useEffect, useState } from "react";
import type { NodeModel, TreeMethods } from "@minoru/react-dnd-treeview";
import { Button, cn } from "@common/components";
import { EditableNode } from "./editable-node";
interface TreeData extends Record<string, unknown> {
children?: unknown[];
}
type CustomNodeProps<T extends TreeData> = {
node: NodeModel<T>;
depth: number;
isOpen: boolean;
isSelected: boolean;
isAdditionalParent: boolean;
treeMethods: TreeMethods | null;
readOnly: boolean;
onToggle: (id: NodeModel["id"]) => void;
onSelect: (node: NodeModel<T>) => void;
updateNodeName: (id: NodeModel["id"], nodeName: string) => Promise<void>;
};
function CustomNode<T extends TreeData>({
node,
depth,
isOpen,
isSelected,
isAdditionalParent,
treeMethods,
readOnly,
onToggle,
onSelect,
updateNodeName,
}: CustomNodeProps<T>) {
const { id, text, data } = node;
const indent = depth * 10 + 5;
const [editMode, setEditMode] = useState(false);
const [nodeName, setNodeName] = useState(text);
const handleToggle = () => onToggle(id);
const handleSelect = () => onSelect(node);
const handleSave = async () => {
await updateNodeName(id, nodeName);
setEditMode(false);
};
const handleCancel = () => {
setEditMode(false);
setNodeName(text);
};
useEffect(() => {
if (isAdditionalParent) treeMethods?.open(id);
}, [isAdditionalParent]);
return (
<div
style={{ paddingInlineStart: indent }}
className={cn(
"flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent",
isSelected && "!bg-[rgba(32,148,250,0.2)]",
!readOnly && "group",
)}
>
{editMode ? (
<EditableNode
value={nodeName}
onChange={(e) => setNodeName(e.target.value)}
onSave={handleSave}
onCancel={handleCancel}
/>
) : (
<>
<div className="flex items-center" onClick={handleSelect}>
{data && data.children && data.children.length > 0 && (
<i
className={cn(
"diveicon di-arrow-forward mr-2 text-base text-label",
isOpen && "rotate-90",
)}
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
/>
)}
<span className="text-[1.4rem] font-medium text-label">{text}</span>
</div>
<Button
variant="ghost"
size="icon"
className="invisible !bg-transparent group-hover:visible"
onClick={() => setEditMode(true)}
>
<i className="diveicon di-pencil" />
</Button>
</>
)}
</div>
);
}
export { CustomNode };
@@ -1,138 +0,0 @@
"use client";
import React, { type ReactElement, useRef } from "react";
import { DndProvider } from "react-dnd";
import {
Tree,
MultiBackend,
getBackendOptions,
type NodeModel,
type TreeMethods,
type DropOptions,
type Classes,
} from "@minoru/react-dnd-treeview";
import { Button, cn } from "@common/components";
import { CustomNode } from "./custom-node";
interface TreeData extends Record<string, unknown> {
children?: unknown[];
}
type EditableNodeProps = { depth: number };
type DndTreeProps<T extends TreeData> = {
tree: NodeModel<T>[];
selectedNodeId?: number;
classes?: {
wrapper?: string;
} & Classes<T>;
renderEditableNode?: (props: EditableNodeProps) => ReactElement;
handleSelectNode?: (node: NodeModel<T>) => void;
handleDropNode?: (newTree: NodeModel<T>[], options: DropOptions<T>) => void;
updateNodeName?: (id: NodeModel["id"], nodeName: string) => Promise<void>;
};
function DndTree<T extends TreeData>({
tree,
selectedNodeId,
classes,
renderEditableNode,
handleSelectNode,
handleDropNode,
updateNodeName,
}: DndTreeProps<T>) {
const treeMethods = useRef<TreeMethods>(null);
const isAdditionalParent = (node: NodeModel<T>): boolean => {
const additionalNode = tree.find((n) => n.id === -1);
if (!additionalNode) return false;
return additionalNode?.parent === node.id;
};
return (
<>
<div
className={cn(
"max-h-[calc(100vh-33.5rem)] overflow-auto",
classes?.wrapper,
)}
>
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={tree}
rootId={0}
ref={treeMethods}
render={(node: NodeModel<T>, { depth, isOpen, onToggle }) => {
if (node.id === -1)
return renderEditableNode?.({ depth }) ?? <></>;
return (
<CustomNode
node={node}
depth={depth}
treeMethods={treeMethods.current}
isOpen={isOpen}
isSelected={selectedNodeId === node.id}
isAdditionalParent={isAdditionalParent(node)}
readOnly={typeof updateNodeName === "undefined"}
onToggle={onToggle}
onSelect={(node) => handleSelectNode?.(node)}
updateNodeName={(id, nodeName) =>
updateNodeName?.(id, nodeName) ?? Promise.resolve()
}
/>
);
}}
onDrop={(newTree, options) => handleDropNode?.(newTree, options)}
classes={{
root: "p-4",
container: "flex flex-col",
dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm",
placeholder: "relative",
...classes,
}}
sort={false}
dropTargetOffset={5}
insertDroppableFirst={false}
canDrop={(_, { dragSource, dropTargetId }) => {
if (dragSource?.parent === dropTargetId) {
return true;
}
}}
placeholderRender={(_, { depth }) => {
return (
<div
className="absolute right-0 top-0 h-[0.2rem] bg-primary"
style={{ left: depth * 10 + 5 }}
/>
);
}}
/>
</DndProvider>
</div>
<div className="flex gap-2">
<Button
color="lightGray"
className="flex-1 items-center px-0"
onClick={() => {
treeMethods.current?.openAll();
}}
>
<i className="diveicon di-arrows-angle-expand" />
전체펼침
</Button>
<Button
color="lightGray"
className="flex-1 items-center px-0"
onClick={() => {
treeMethods.current?.closeAll();
}}
>
<i className="diveicon di-arrows-angle-contract" />
전체접기
</Button>
</div>
</>
);
}
export { DndTree };
@@ -1,44 +0,0 @@
import type { ComponentProps } from "react";
import { Button, Input } from "@common/components";
type EditableNodeProps = {
depth?: number;
onSave: () => void;
onCancel: () => void;
} & ComponentProps<typeof Input>;
function EditableNode({
depth,
onSave,
onCancel,
...props
}: EditableNodeProps) {
return (
<div
className="flex items-center gap-2 rounded-sm bg-accent p-2"
style={{ marginInlineStart: !depth ? 0 : depth * 5 + 5 }}
>
<Input {...props} className="h-[2.5rem] !text-[1.3rem] text-foreground" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8 !bg-transparent"
onClick={onSave}
>
<i className="diveicon di-check text-2xl" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 !bg-transparent"
onClick={onCancel}
>
<i className="diveicon di-x" />
</Button>
</div>
</div>
);
}
export { EditableNode };
@@ -1 +0,0 @@
export { FormWrapper } from "./ui/form-wrapper";
@@ -1,63 +0,0 @@
import { useRouter } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { type FieldValues, type UseFormReturn } from "react-hook-form";
import { Button } from "@common/components";
import { Form } from "@/shared/ui/form";
type FormWrapperProps<T extends FieldValues> = {
form: UseFormReturn<T>;
children: React.ReactNode;
isModifyMode?: boolean;
handleSave: (data: T) => Promise<unknown>;
handleDelete?: () => Promise<unknown>;
};
function FormWrapper<T extends FieldValues>({
form,
children,
isModifyMode,
handleSave,
handleDelete,
}: FormWrapperProps<T>) {
const router = useRouter();
const { confirm } = useConfirm();
const onSubmit = async (data: T) => {
const confirmMessage = isModifyMode
? "수정하시겠습니까?"
: "저장하시겠습니까?";
if (await confirm(confirmMessage)) {
await handleSave(data);
}
};
const onDelete = async () => {
if (await confirm("삭제하시겠습니까?")) {
await handleDelete?.();
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{children}
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button size="lg" color="white" type="button" onClick={router.back}>
목록
</Button>
<Button size="lg" type="submit">
저장
</Button>
{isModifyMode && handleDelete && (
<Button size="lg" color="red" type="button" onClick={onDelete}>
삭제
</Button>
)}
</div>
</form>
</Form>
);
}
export { FormWrapper };
@@ -1,14 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
PostEvaluationPayload,
PostEvaluationService,
} from "../model/post-evaluation-interface";
export class PostEvaluationApiService implements PostEvaluationService {
public async postEvaluation(payload: PostEvaluationPayload) {
await client.post(ENDPOINT.BOARD_SERVICE.POST_EVALUATION, payload);
}
}
export const PostEvaluationApi = new PostEvaluationApiService();
-5
View File
@@ -1,5 +0,0 @@
export { Post } from "./ui/post";
export { PostForm } from "./ui/post-form";
export { PostPage } from "./ui/post-page";
export { SortSelect } from "./ui/sort-select";
export { GeneralFileUploader } from "./ui/general-file-uploader";
@@ -1,184 +0,0 @@
import dayjs from "dayjs";
import Image from "next/image";
import { useQuery } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge, Checkbox, Skeleton, cn } from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import type { Post } from "@/entities/post";
import type { BbsType } from "@/shared/store";
import { ENDPOINT } from "@/shared/config";
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>>,
): ColumnDef<Post>[] => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row, 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}
/>
<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>
);
}
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>
);
}
},
},
];
type ThumbImageProps = {
atflCd: string;
pstNo: number;
thumbRefs: React.MutableRefObject<Map<number, string>>;
bbsTypeCd?: string;
classes?: {
image?: string;
};
};
const ThumbImage = ({
atflCd,
pstNo,
thumbRefs,
bbsTypeCd,
classes,
}: ThumbImageProps) => {
const prevImage = thumbRefs.current.get(pstNo);
const { data, isLoading } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, 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;
},
enabled: !prevImage && !!atflCd,
});
const imageUrl = prevImage ?? data;
if (isLoading)
return (
<div className="h-[16rem] w-full">
<Skeleton className="h-full w-full" />
</div>
);
if (imageUrl)
return (
<div className="relative flex h-[16rem] w-full items-center justify-center">
<Image
src={imageUrl}
className={cn("object-contain", classes?.image)}
fill
alt="썸네일 이미지"
/>
</div>
);
return (
<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>
);
};
@@ -1,10 +0,0 @@
export interface PostEvaluationPayload {
pstNo: number;
evlIp?: string;
evlDt?: string;
evlScr: number;
}
export interface PostEvaluationService {
postEvaluation: (payload: PostEvaluationPayload) => Promise<void>;
}
@@ -1,146 +0,0 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge, Button, Checkbox } from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import type { Post } from "@/entities/post";
export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
accessorKey: "rowNo",
header: "NO",
cell: ({ row, table }) => {
if (row.original.isUpndFixPost) {
return (
<Badge
color="orange"
className="flex size-8 items-center justify-center"
>
<i className="diveicon di-volume-up-fill" />
</Badge>
);
}
const totalRows = table.getRowCount();
const currentPage = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return totalRows - (currentPage * pageSize + row.index);
},
},
{
accessorKey: "pstTtl",
header: "제목",
cell: ({ row }) => {
const { ansPstn = 0, pstTtl, inqNocs, cmntCnt } = row.original;
const indentPadding = ansPstn > 0 ? (ansPstn - 1) * 20 : 0;
return (
<div
className="flex min-w-[20vw] items-center gap-2 text-left"
style={{ paddingInlineStart: `${indentPadding}px` }}
>
{ansPstn > 0 && (
<div className="flex items-center gap-1">
<div className="-rotate-90">
<i className="diveicon di-arrow-90left-down rotate-90" />
</div>
<Badge color="smokyBlue">답변</Badge>
</div>
)}
{inqNocs >= 100 && <Badge color="red">HOT</Badge>}
{dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && (
<Badge color="blue">NEW</Badge>
)}
{pstTtl}
{cmntCnt > 0 && (
<div className="ml-4 flex items-end gap-2">
<i className="diveicon di-chat-box-left-text text-gray-400" />
<span className="text-gray-400">{cmntCnt}</span>
</div>
)}
</div>
);
},
},
{
accessorKey: "inqNocs",
header: "조회수",
},
{
accessorKey: "atflCd",
header: "첨부파일",
cell: ({ row }) => {
const atflCd = row.original.atflCd;
if (!atflCd) return null;
return <AttachmentButton atflCd={atflCd} />;
},
},
{
accessorKey: "rgtrNm",
header: "작성자",
},
{
accessorKey: "regDt",
header: "등록일",
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"),
},
];
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>
);
};
@@ -1,121 +0,0 @@
import { useEffect, useState } from "react";
import type { FieldPath, UseFormReturn } from "react-hook-form";
import type { HttpResponse, Upload } from "tus-js-client";
import { Button, FileUploader } from "@common/components";
import { handleTusUpload } from "@/entities/tus";
import { type Attachment } from "@/entities/attachment";
import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib";
function GeneralFileUploader<T extends { atflCd?: string | null }>({
form,
attachmentData,
deleteAttachmentIds,
setDeleteAttachmentIds,
}: {
form: UseFormReturn<T>;
attachmentData?: Attachment[];
deleteAttachmentIds: string[];
setDeleteAttachmentIds: (deleteAttachmentIds: string[]) => void;
}) {
const alert = useAlertStore((state) => state.setMessage);
const bbsInfo = useMenuStore((state) => state.bbsInfo);
const [attachments, setAttachments] = useState<Record<string, Attachment>>(
{},
);
const handleTusResponse = (res: HttpResponse, upload: Upload) => {
const fileInfo = res.getHeader("X-File-Info");
if (
typeof fileInfo !== "string" ||
fileInfo === undefined ||
fileInfo === null
)
return;
const { atflCd, atflId } = JSON.parse(fileInfo) ?? {};
if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd);
if (atflId) {
setAttachments((prev) => ({
...prev,
[atflId]: {
atflCd,
atflId,
orgnlFileNm: (upload.file as File).name,
atflFlieSz: (upload.file as File).size,
atflType: (upload.file as File).type,
},
}));
}
};
const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const atflCd = form.getValues("atflCd" as FieldPath<T>);
if (atflCd) setCookie("atfl-cd-key", atflCd as string);
const files = e.target.files;
if (!files) return;
if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) {
alert("첨부파일 최대 개수를 초과했습니다.");
return;
}
for (const file of files) {
const fileSize = Math.round(file.size / 1024); // Byte -> KB
if (bbsInfo?.atflMaxFileSz && fileSize > bbsInfo.atflMaxFileSz) {
alert("첨부파일 최대 크기를 초과했습니다.");
return;
}
handleTusUpload(file, {
handleResponse: handleTusResponse,
});
}
};
useEffect(() => {
const mapper = (data: Attachment[]) =>
data.reduce(
(acc, curr) => {
acc[curr.atflId] = curr;
return acc;
},
{} as Record<string, Attachment>,
);
if (attachmentData) setAttachments(mapper(attachmentData));
}, [attachmentData]);
return (
<FileUploader
id="atflCd"
multiple
className="flex !h-auto max-w-full flex-row-reverse items-start gap-4 [&>label]:h-[3.2rem]"
onUpload={onUpload}
customPreview={
<div className="flex w-full flex-wrap gap-4">
{Object.entries(attachments).map(([atflId, attachment]) => {
if (deleteAttachmentIds.includes(atflId)) return null;
return (
<div
key={atflId}
className="flex h-[3.2rem] items-center gap-2 rounded-sm border border-border bg-background px-3 py-2 text-foreground"
>
<span>{attachment.orgnlFileNm}</span>
<Button
color="lightGray"
className="flex size-7 shrink-0 items-center justify-center rounded-full p-0"
onClick={() =>
setDeleteAttachmentIds([...deleteAttachmentIds, atflId])
}
>
<i className="diveicon di-x text-xs" />
</Button>
</div>
);
})}
</div>
}
/>
);
}
export { GeneralFileUploader };
-106
View File
@@ -1,106 +0,0 @@
import { toast } from "sonner";
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useClipboard } from "@frontend-opensource/use-react-hooks";
import { Button } from "@common/components";
import { deleteReasonMessages } from "@/entities/post";
import { AuthorityButton } from "@/entities/menu-authority";
import { useMenuStore, useSelectPrompt, useUserStore } from "@/shared/lib";
type PostActionProps = {
bbsId: string;
pstNo: string;
children: React.ReactNode;
handleDelete: (delRsn?: string) => Promise<void>;
};
const selectData = Object.entries(deleteReasonMessages).map(
([label, value]) => ({ label, value }),
);
function PostAction({ children, handleDelete }: PostActionProps) {
const router = useRouter();
const pathname = usePathname();
const { copyText, copied } = useClipboard();
const { prompt } = useSelectPrompt(selectData);
const bbsInfo = useMenuStore((state) => state.bbsInfo);
const userInfo = useUserStore((state) => state.userInfo);
const postPageLink = `/bbs/${bbsInfo?.bbsTypeCd.toLowerCase()}/${bbsInfo?.bbsId}`;
const onDelete = async () => {
const { resolved, value } = await prompt("삭제 사유를 입력해주세요.");
if (resolved) handleDelete(value);
};
const checkAnsUseAuthrt = () => {
// 관리자
if (bbsInfo?.ansUseAuthrt === "A") {
return userInfo?.adminRoleYn === "Y";
}
// 회원
if (bbsInfo?.ansUseAuthrt === "M") {
return !!userInfo;
}
// 비회원
return true;
};
useEffect(() => {
if (copied) toast.success("URL이 클립보드에 복사되었습니다.");
}, [copied]);
return (
<>
{children}
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
size="lg"
color="emerald"
type="button"
onClick={() => copyText(window.location.href)}
>
퍼가기
</Button>
<Button
size="lg"
color="white"
type="button"
onClick={() => router.push(postPageLink)}
>
목록
</Button>
<AuthorityButton
authority="mdfcnAuthrtYn"
size="lg"
type="button"
onClick={() => router.push(`${pathname}/modify`)}
>
수정
</AuthorityButton>
{bbsInfo?.ansUseYn === "Y" && (
<AuthorityButton
authority="regAuthrtYn"
size="lg"
type="button"
color="gray"
onClick={() => router.push(`${pathname}/answer`)}
disabled={!checkAnsUseAuthrt()}
>
답글
</AuthorityButton>
)}
<AuthorityButton
authority="delAuthrtYn"
size="lg"
color="red"
type="button"
onClick={onDelete}
>
삭제
</AuthorityButton>
</div>
</>
);
}
export { PostAction };
@@ -1,323 +0,0 @@
import dayjs from "dayjs";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Input,
Label,
RadioGroup,
RadioGroupItem,
Textarea,
} from "@common/components";
import {
CommentApi,
commentFormSchema,
type CommentFormData,
type CommentUpdateFormData,
} from "@/entities/comment";
import { ENDPOINT } from "@/shared/config";
import {
useAlertStore,
useMenuStore,
useMutationWrapper,
usePrompt,
useUserStore,
} from "@/shared/lib";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) {
const { prompt } = usePrompt();
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const bbsInfo = useMenuStore((state) => state.bbsInfo);
const userInfo = useUserStore((state) => state.userInfo);
const alert = useAlertStore((state) => state.setMessage);
const [editValue, setEditValue] = useState("");
const [editingCmntNo, setEditingCmntNo] = useState<number | null>(null);
const { data: comments } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo],
queryFn: () => CommentApi.getComments({ pstNo }),
enabled: !!pstNo,
});
const { mutate: createComment } = useMutationWrapper({
mutationFn: (data: CommentFormData) => CommentApi.createComment(data),
onSuccess: () => {
alert("등록되었습니다.");
form.setValue("cmntCn", "");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
});
},
});
const { mutate: updateComment } = useMutationWrapper({
mutationFn: ({
cmntNo,
data,
}: {
cmntNo: number;
data: CommentUpdateFormData;
}) => CommentApi.updateComment(bbsId, pstNo, cmntNo, data),
onSuccess: () => {
alert("수정되었습니다.");
setEditingCmntNo(null);
setEditValue("");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
});
},
});
const { mutate: deleteComment } = useMutationWrapper({
mutationFn: ({ cmntNo, cmntPswd }: { cmntNo: number; cmntPswd: string }) =>
CommentApi.deleteComment(bbsId, pstNo, cmntNo, cmntPswd),
onSuccess: () => {
alert("삭제되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
});
},
});
const form = useForm<CommentFormData>({
resolver: zodResolver(commentFormSchema),
values: {
pstNo,
cmntCn: "",
wrtrNm: userInfo?.userName ?? "",
cmntPswd: userInfo?.userUnqId ?? "",
},
});
const onSubmit = (data: CommentFormData) => {
createComment(data);
};
const getCommentPassword = async () => {
let cmntPswd = "";
if (userInfo?.userUnqId) {
cmntPswd = userInfo?.userUnqId;
} else {
const { resolved, value } = await prompt("비밀번호를 입력해주세요.");
if (resolved) cmntPswd = value ?? "";
}
return cmntPswd;
};
const handleUpdateComment = async (cmntNo: number, wrtrNm: string) => {
if (!(await confirm("수정하시겠습니까?"))) return;
const cmntPswd = await getCommentPassword();
updateComment({
cmntNo,
data: {
cmntCn: editValue,
wrtrNm,
cmntPswd,
},
});
};
const handleDeleteComment = async (cmntNo: number) => {
if (!(await confirm("삭제하시겠습니까?"))) return;
const cmntPswd = await getCommentPassword();
deleteComment({ cmntNo, cmntPswd });
};
return (
<div className="card card-border !p-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{bbsInfo?.cmntUseYn === "C" && (
<FormField
control={form.control}
name="cmntEvl"
render={({ field }) => (
<FormItem className="mb-4">
<FormControl>
<RadioGroup
name="cmntEvl"
value={field.value?.toString() ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="5" id="cmntEvl5" />
<Label htmlFor="cmntEvl5">매우만족 ★★★★★</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="4" id="cmntEvl4" />
<Label htmlFor="cmntEvl4">만족 ★★★★</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="3" id="cmntEvl3" />
<Label htmlFor="cmntEvl3">보통 ★★★</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="2" id="cmntEvl2" />
<Label htmlFor="cmntEvl2">불만족 ★★</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="1" id="cmntEvl1" />
<Label htmlFor="cmntEvl1">매우불만족 ★</Label>
</div>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="cmntCn"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
maxLength={1000}
className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6"
placeholder="댓글을 입력해주세요."
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<div className="mt-4 flex items-center justify-between">
<div className="flex gap-4">
{!userInfo?.userName && (
<FormField
control={form.control}
name="wrtrNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[16rem] bg-background !text-[1.3rem]"
placeholder="작성자"
/>
</FormControl>
</FormItem>
)}
/>
)}
{!userInfo?.userUnqId && (
<FormField
control={form.control}
name="cmntPswd"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="password"
value={field.value ?? ""}
className="w-[12rem] bg-background !text-[1.3rem]"
placeholder="비밀번호"
/>
</FormControl>
</FormItem>
)}
/>
)}
</div>
<Button type="submit">등록</Button>
</div>
</form>
</Form>
<div className="mt-8 flex flex-col">
{comments?.content.map((comment) => {
if (comment.delYn === "Y") return null;
return (
<div
key={comment.cmntNo}
className="flex flex-col border-t border-gray-200 py-8"
>
<div>
{Array.from({ length: Number(comment.cmntEvl) }).map(
(_, index) => (
<span key={index}>★</span>
),
)}
</div>
<div className="flex justify-between">
<div className="flex w-full gap-10">
<div className="flex w-[10rem] shrink-0 flex-col">
<span className="text-[1.4rem] font-bold text-label">
{comment.wrtrNm}
</span>
<span className="text-[1.2rem] text-label">
{dayjs(comment.regDt).format("YYYY.MM.DD")}
</span>
</div>
{editingCmntNo === comment.cmntNo ? (
<div className="flex flex-1 flex-col gap-2">
<Textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
maxLength={1000}
className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6"
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() =>
handleUpdateComment(comment.cmntNo, comment.wrtrNm)
}
>
저장
</Button>
<Button
size="sm"
color="gray"
onClick={() => {
setEditingCmntNo(null);
setEditValue("");
}}
>
취소
</Button>
</div>
</div>
) : (
<p className="break-all text-[1.4rem] text-foreground">
{comment.cmntCn}
</p>
)}
</div>
<div className="flex gap-2 whitespace-nowrap">
<Button
size="icon"
color="white"
onClick={() => {
setEditingCmntNo(comment.cmntNo);
setEditValue(comment.cmntCn);
}}
>
<i className="diveicon di-pencil" />
</Button>
<Button
size="icon"
color="white"
onClick={() => handleDeleteComment(comment.cmntNo)}
>
<i className="diveicon di-trash" />
</Button>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
export { PostComment };
-449
View File
@@ -1,449 +0,0 @@
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { UseFormReturn, Path } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Input,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Textarea,
Button,
} from "@common/components";
import { BOARD_POSTING_CODE } from "@/entities/board";
import { AttachmentApi } from "@/entities/attachment";
import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useUserStore, setCookie, useMenuStore } from "@/shared/lib";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
import { PostingPeriodSelector } from "./posting-period-selector";
import { GeneralFileUploader } from "./general-file-uploader";
import { WithPreviewFileUploader } from "./with-preview-file-uploader";
type PostFormProps<T extends PostFormData | PostUpdateFormData> = {
form: UseFormReturn<T>;
handleSave: (data: T) => Promise<Post>;
};
function PostForm<T extends PostFormData | PostUpdateFormData>({
form,
handleSave,
}: PostFormProps<T>) {
const atflCd = form.getValues("atflCd" as Path<T>);
const router = useRouter();
const { confirm } = useConfirm();
const bbsInfo = useMenuStore((state) => state.bbsInfo);
const userInfo = useUserStore((state) => state.userInfo);
const [deleteAttachmentIds, setDeleteAttachmentIds] = useState<string[]>([]);
const { data: attachmentData } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd],
queryFn: () => AttachmentApi.getAttachmentsInfo(atflCd as string),
enabled: !!atflCd,
});
const onSubmit = async (data: T) => {
if (await confirm("저장하시겠습니까?")) {
if (deleteAttachmentIds.length > 0) {
await Promise.all(
deleteAttachmentIds.map(AttachmentApi.deleteAttachmentByAtflId),
);
}
await handleSave(data);
}
};
useEffect(() => {
setCookie("atfl-cd-key", "");
}, []);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="85%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead data-required>제목</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"pstTtl" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={(field.value as string) ?? ""}
className="!text-[1.3rem]"
placeholder="제목을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>작성자</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"wrtrNm" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={(field.value as string) ?? ""}
className="w-[20rem] !text-[1.3rem]"
placeholder="작성자를 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{!userInfo?.userUnqId && (
<TableRow>
<TableHead data-required>비밀번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"pstPswd" as Path<T>}
render={({ field }) => (
<FormItem className="flex flex-col">
<FormControl>
<Input
{...field}
type="password"
value={(field.value as string) ?? ""}
className="w-[20rem] !text-[1.3rem]"
placeholder="비밀번호를 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
<TableRow>
<TableHead>내용</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"pstCn" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
{...field}
value={(field.value as string) ?? ""}
onChange={field.onChange}
className="h-[20rem] w-full bg-form !text-[1.3rem] !leading-6"
placeholder="내용을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{bbsInfo?.rfrncUseYn === "Y" && (
<TableRow>
<TableHead>참조 URL</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"rfrncUrl" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={(field.value as string) ?? ""}
className="!text-[1.3rem]"
placeholder="URL을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
{bbsInfo?.popupUseYn === "Y" && (
<TableRow>
<TableHead>팝업여부</TableHead>
<TableCell className="border">
<div className="flex items-center gap-4">
<FormField
control={form.control}
name={"popupYn" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
name="popupYn"
value={field.value?.toString() ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="popupY" />
<Label htmlFor="popupY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="popupN" />
<Label htmlFor="popupN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={"popupYnEtc" as Path<T>}
render={({ field }) => {
const fieldValues = form.getValues().popupYnEtc?.[0];
return (
<FormItem>
<FormControl>
<PostingPeriodSelector
defaultValues={{ ...fieldValues }}
disabled={
form.getValues("popupYn" as Path<T>) === "N"
}
handlePeriodValueChange={(
periodCode,
date,
) => {
field.onChange([
{
...fieldValues,
...date,
bbsPstgTypeCd:
BOARD_POSTING_CODE.팝업게시,
pstgPrdSeCd: periodCode,
},
]);
}}
handlePostingDateChange={(date) => {
field.onChange([
{ ...fieldValues, ...date },
]);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
</TableCell>
</TableRow>
)}
{bbsInfo?.upndFixUseYn === "Y" && (
<TableRow>
<TableHead>상단고정</TableHead>
<TableCell className="border">
<div className="flex items-center gap-4">
<FormField
control={form.control}
name={"upndFixYn" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
name="upndFixYn"
value={field.value?.toString() ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="upndFixY" />
<Label htmlFor="upndFixY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="upndFixN" />
<Label htmlFor="upndFixN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={"upndFixYnEtc" as Path<T>}
render={({ field }) => {
const fieldValues =
form.getValues().upndFixYnEtc?.[0];
return (
<FormItem>
<FormControl>
<PostingPeriodSelector
defaultValues={{ ...fieldValues }}
disabled={
form.getValues("upndFixYn" as Path<T>) ===
"N"
}
handlePeriodValueChange={(
periodCode,
date,
) => {
field.onChange([
{
...fieldValues,
...date,
bbsPstgTypeCd:
BOARD_POSTING_CODE.상단게시,
pstgPrdSeCd: periodCode,
},
]);
}}
handlePostingDateChange={(date) => {
field.onChange([
{ ...fieldValues, ...date },
]);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
</TableCell>
</TableRow>
)}
{bbsInfo?.pstgPrdUseYn === "Y" && (
<TableRow>
<TableHead>게시기간</TableHead>
<TableCell className="border">
<div className="flex items-center gap-4">
<FormField
control={form.control}
name={"prdPstgYn" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
name="prdPstgYn"
value={field.value?.toString() ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="prdPstgY" />
<Label htmlFor="prdPstgY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="prdPstgN" />
<Label htmlFor="prdPstgN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={"prdPstgYnEtc" as Path<T>}
render={({ field }) => {
const fieldValues =
form.getValues().prdPstgYnEtc?.[0];
return (
<FormItem>
<FormControl>
<PostingPeriodSelector
defaultValues={{ ...fieldValues }}
disabled={
form.getValues("prdPstgYn" as Path<T>) ===
"N"
}
handlePeriodValueChange={(
periodCode,
date,
) => {
field.onChange([
{
...fieldValues,
...date,
bbsPstgTypeCd:
BOARD_POSTING_CODE.기간게시,
pstgPrdSeCd: periodCode,
},
]);
}}
handlePostingDateChange={(date) => {
field.onChange([
{ ...fieldValues, ...date },
]);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
</TableCell>
</TableRow>
)}
{bbsInfo?.atflUseYn === "Y" && (
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell className="border">
{bbsInfo?.bbsTypeCd === "BBSIMAGE" ||
bbsInfo?.bbsTypeCd === "BBSMOVIE" ? (
<WithPreviewFileUploader
form={form}
attachmentData={attachmentData}
/>
) : (
<GeneralFileUploader
form={form}
attachmentData={attachmentData}
deleteAttachmentIds={deleteAttachmentIds}
setDeleteAttachmentIds={setDeleteAttachmentIds}
/>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
size="lg"
color="white"
type="button"
onClick={() => router.back()}
>
취소
</Button>
<Button size="lg" type="submit">
저장
</Button>
</div>
</form>
</Form>
);
}
export { PostForm };
-239
View File
@@ -1,239 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import {
Button,
DataTable,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
} from "@common/components";
import { AuthorityButton } from "@/entities/menu-authority";
import {
PostApi,
PostPageGuard,
deleteReasonMessages,
type Post,
type PostParams,
} from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useMenuStore, useSelectPrompt } from "@/shared/lib";
import { GridViewDataTable } from "@/shared/ui/grid-view-data-table";
import { columns as tableColumns } from "../model/post-table-columns";
import { createColumns as createGridViewColumns } from "../model/grid-view-table-columns";
import { SortSelect } from "./sort-select";
const selectData = Object.entries(deleteReasonMessages).map(
([label, value]) => ({ label, value }),
);
function PostPage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const pathname = usePathname();
const { prompt } = useSelectPrompt(selectData);
const bbsInfo = useMenuStore((state) => state.bbsInfo);
const thumbRefs = useRef<Map<number, string>>(new Map());
const [tableSort, setTableSort] = useState("");
const [postParams, setPostParams] = useState<PostParams>({
keywordType: "title",
keyword: "",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { data: upndFixPostList } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, bbsInfo],
queryFn: () =>
PostApi.getPostList(parseInt(bbsId), {
...postParams,
upndFixYn: bbsInfo?.upndFixUseYn,
}),
select: (data) => {
const newContent = data.content.map((post) => ({
...post,
isUpndFixPost: post.upndFixYn === "Y",
}));
return { ...data, content: newContent };
},
enabled: bbsInfo?.upndFixUseYn === "Y",
});
const {
data: postList,
isLoading,
refetch,
} = useQuery({
queryKey: [
ENDPOINT.BOARD_SERVICE.POSTS,
bbsInfo?.bbsTypeCd,
pagination,
tableSort,
],
queryFn: () =>
PostApi.getPostList(parseInt(bbsId), {
...postParams,
bbsTypeCd: bbsInfo?.bbsTypeCd,
ansUseYn: bbsInfo?.ansUseYn,
page: pagination.pageIndex,
size: pagination.pageSize,
sort: tableSort,
}),
enabled: !!bbsInfo?.bbsTypeCd,
});
const isGridViewPage =
bbsInfo?.bbsTypeCd === "BBSIMAGE" || bbsInfo?.bbsTypeCd === "BBSMOVIE";
const table = useReactTable({
data: isGridViewPage
? (postList?.content ?? [])
: [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])],
columns: isGridViewPage ? createGridViewColumns(thumbRefs) : tableColumns,
state: { pagination, rowSelection },
rowCount: postList?.totalElements ?? 0,
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
meta: { bbsTypeCd: bbsInfo?.bbsTypeCd },
});
const DataTableComponent = isGridViewPage ? GridViewDataTable : DataTable;
const onPostDelete = async () => {
const selectedPosts = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original.pstNo);
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>) => {
e.preventDefault();
refetch();
};
const handleRowClick = (row: Post) => {
router.push(`${pathname}/${row.pstNo}`);
};
return (
<PostPageGuard bbsId={bbsId}>
<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">
<AuthorityButton
authority="regAuthrtYn"
onClick={() => router.push(`${pathname}/register`)}
>
등록
</AuthorityButton>
<AuthorityButton
authority="delAuthrtYn"
color="red"
onClick={onPostDelete}
disabled={!table.getFilteredSelectedRowModel().rows.length}
>
선택삭제
</AuthorityButton>
<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>
{bbsInfo?.ansUseYn === "N" && (
<SortSelect value={tableSort} onValueChange={setTableSort} />
)}
</div>
</div>
</div>
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
{isLoading ? (
<Skeleton className="h-[36rem] w-full" />
) : (
<DataTableComponent
table={table}
onRowClick={handleRowClick}
viewPagination
/>
)}
</div>
</div>
</div>
</PostPageGuard>
);
}
export { PostPage };
-263
View File
@@ -1,263 +0,0 @@
"use client";
import dayjs from "dayjs";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
Button,
Label,
RadioGroup,
RadioGroupItem,
Skeleton,
} from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import { PostApi } from "@/entities/post";
import { BoardApi } from "@/entities/board";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper, useMenuStore } from "@/shared/lib";
import { PostAction } from "./post-action";
import { PostComment } from "./post-comment";
import { PostEvaluationApi } from "../api/post-evaluation-service";
function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const router = useRouter();
const alert = useAlertStore((state) => state.setMessage);
const { bbsInfo, setBbsInfo } = useMenuStore((state) => state);
const [filename, setFilename] = useState("");
const [evlScr, setEvlScr] = useState(5);
const { data: boardInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.BBS, bbsId],
queryFn: () => BoardApi.getBoardInfo(bbsId),
});
const { data: postData } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, 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" &&
bbsInfo?.bbsTypeCd !== "BBSMOVIE",
});
const { data: imageUrl, isLoading } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, postData?.atflCd],
queryFn: () =>
AttachmentApi.getImageUrlByAtflCd(postData?.atflCd as 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 { mutateAsync: deletePost } = useMutationWrapper({
mutationFn: (delRsn?: string) =>
PostApi.deletePost(parseInt(bbsId), parseInt(pstNo), delRsn),
onSuccess: () => {
alert("삭제되었습니다.", router.back);
},
});
const { mutate: postEvaluation } = useMutationWrapper({
mutationFn: (evlScr: number) =>
PostEvaluationApi.postEvaluation({
pstNo: parseInt(pstNo),
evlScr,
}),
onSuccess: () => {
alert("게시물 평가가 완료되었습니다.");
},
});
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]);
useEffect(() => {
if (!boardInfo) return;
setBbsInfo(boardInfo);
}, [boardInfo]);
return (
<>
<PostAction bbsId={bbsId} pstNo={pstNo} handleDelete={deletePost}>
<div className="card card-border !p-8">
<h3 className="text-[2rem] font-bold text-foreground">
{postData?.pstTtl}
</h3>
<div className="card !m-0 !mt-8 bg-accent">
<ul className="flex gap-8 p-4 text-xl text-label">
<li>
작성자: <b>{postData?.wrtrNm}</b>
</li>
<li>
등록일: <b>{dayjs(postData?.regDt).format("YYYY-MM-DD")}</b>
</li>
<li>
조회수: <b>{postData?.inqNocs}</b>
</li>
</ul>
</div>
<hr className="mt-8" />
{bbsInfo?.bbsTypeCd === "BBSIMAGE" && (
<>
{isLoading && (
<div className="relative my-6 flex h-[75rem] w-[100rem]">
<Skeleton className="h-full w-full" />
</div>
)}
{imageUrl && (
<div className="relative my-6 flex h-[75rem] w-[100rem]">
<Image
src={imageUrl}
className="object-cover"
fill
alt="썸네일 이미지"
/>
</div>
)}
</>
)}
{bbsInfo?.bbsTypeCd === "BBSMOVIE" && (
<div className="relative my-6 flex h-[56.25rem] w-[100rem]">
<video src={videoUrl} controls className="object-contain" />
</div>
)}
<pre className="min-h-[40rem] whitespace-pre-wrap p-8 font-noto text-2xl tracking-tighter text-foreground">
{postData?.pstCn ?? ""}
</pre>
{isFetching ? (
<div className="p-8">
<Skeleton className="h-[2.5rem] w-[32rem]" />
</div>
) : (
filename && (
<>
<hr />
<div className="flex items-center gap-4 p-8">
<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>
</>
)
)}
{bbsInfo?.dgstfnEvlUseYn === "Y" && (
<>
<hr />
<div className="mt-8 flex items-center gap-2 px-4">
<p>
이 페이지에서 제공하는
<br />
정보에 대하여 만족하시나요?
</p>
<div className="ml-4 rounded-sm border border-border bg-accent px-8 py-4">
<RadioGroup
value={evlScr.toString()}
onValueChange={(value) => setEvlScr(parseInt(value))}
className="flex items-center space-x-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="5" id="evl5" />
<Label htmlFor="evl5">매우 만족 😊</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="4" id="evl4" />
<Label htmlFor="evl4">만족 🙂</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="3" id="evl3" />
<Label htmlFor="evl3">보통 😐</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="2" id="evl2" />
<Label htmlFor="evl2">불만족 🙁</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="1" id="evl1" />
<Label htmlFor="evl1">매우 불만족 😞</Label>
</div>
</RadioGroup>
</div>
<Button
size="lg"
color="white"
onClick={() => postEvaluation(evlScr)}
>
평가하기
</Button>
</div>
</>
)}
</div>
</PostAction>
<PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />
</>
);
}
export { Post };
@@ -1,84 +0,0 @@
import dayjs from "dayjs";
import { useState } from "react";
import { CodeSelect } from "@/entities/code";
import { DateRangePicker } from "@common/components";
type PostingDate = {
pstgBgngDt: string | undefined;
pstgEndDt: string | undefined;
};
function PostingPeriodSelector({
defaultValues,
disabled,
handlePeriodValueChange,
handlePostingDateChange,
}: {
defaultValues?: {
pstgPrdSeCd?: string | null;
pstgBgngDt?: string | null;
pstgEndDt?: string | null;
};
disabled: boolean;
handlePeriodValueChange: (periodCode: string, date?: PostingDate) => void;
handlePostingDateChange: (date?: PostingDate) => void;
}) {
const [periodCode, setPeriodCode] = useState<string | null>(null);
const onPeriodCodeChange = (periodCode: string) => {
setPeriodCode(periodCode);
handlePeriodValueChange(periodCode, {
pstgBgngDt: `${dayjs().format("YYYY-MM-DD 00:00:00")}`,
pstgEndDt: `${dayjs().add(parseInt(periodCode), "day").format("YYYY-MM-DD 23:59:59")}`,
});
};
const onDateChange = (date?: {
from: Date | undefined;
to?: Date | undefined;
}) => {
handlePostingDateChange({
pstgBgngDt: dayjs(date?.from).format("YYYY-MM-DD 00:00:00"),
pstgEndDt: dayjs(date?.to).format("YYYY-MM-DD 23:59:59"),
});
};
const postingPeriodFormat = (periodCode: number | null) => {
if (!periodCode) return "";
if (periodCode === 99) return "상시";
return `${dayjs().format("YYYY-MM-DD")} ~ ${dayjs()
.add(periodCode, "day")
.format("YYYY-MM-DD")}`;
};
return (
<div className="flex items-center gap-4">
<CodeSelect
upCd="PSTGPRDCD"
defaultValue={defaultValues?.pstgPrdSeCd ?? ""}
onValueChange={onPeriodCodeChange}
disabled={disabled}
placeholder="기간 선택"
/>
{periodCode ? (
<span className="text-lg text-label">
게시 기간: <u>{postingPeriodFormat(parseInt(periodCode))}</u>
</span>
) : (
defaultValues?.pstgBgngDt &&
defaultValues?.pstgEndDt && (
<span className="text-lg text-label">
게시 기간:{" "}
<u>
{dayjs(defaultValues?.pstgBgngDt).format("YYYY-MM-DD")} ~{" "}
{dayjs(defaultValues?.pstgEndDt).format("YYYY-MM-DD")}
</u>
</span>
)
)}
{periodCode === "0" && <DateRangePicker onValueChange={onDateChange} />}
</div>
);
}
export { PostingPeriodSelector };
@@ -1,32 +0,0 @@
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@common/components";
type SortSelectProps = {
value: string;
onValueChange: (value: string) => void;
};
function SortSelect(props: SortSelectProps) {
return (
<Select {...props}>
<SelectTrigger className="h-[3.2rem] w-[8rem]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pstTtl,asc">제목&uarr;</SelectItem>
<SelectItem value="pstTtl,desc">제목&darr;</SelectItem>
<SelectItem value="rgtrNm,asc">작성자&uarr;</SelectItem>
<SelectItem value="rgtrNm,desc">작성자&darr;</SelectItem>
<SelectItem value="regDt,asc">등록일&uarr;</SelectItem>
<SelectItem value="regDt,desc">등록일&darr;</SelectItem>
</SelectContent>
</Select>
);
}
export { SortSelect };
@@ -1,125 +0,0 @@
import Image from "next/image";
import { useEffect, useState } from "react";
import type { HttpResponse } from "tus-js-client";
import type { FieldPath, UseFormReturn } from "react-hook-form";
import { FileUploader, Label, Progress } from "@common/components";
import { handleTusUpload } from "@/entities/tus";
import { AttachmentApi, type Attachment } from "@/entities/attachment";
import type { PostFormData, PostUpdateFormData } from "@/entities/post";
import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import type { BbsType } from "@/shared/store";
function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({
form,
attachmentData,
}: {
form: UseFormReturn<T>;
attachmentData?: Attachment[];
}) {
const alert = useAlertStore((state) => state.setMessage);
const bbsInfo = useMenuStore((state) => state.bbsInfo);
const [thumbnailUrl, setThumbnailUrl] = useState("");
const [progress, setProgress] = useState(0);
const fileAccept = (bbsTypeCd: BbsType) => {
switch (bbsTypeCd) {
case "BBSIMAGE":
return ".jpeg,.jpg,.png";
case "BBSMOVIE":
return ".mp4,.mov,.avi,.wmv,.mkv";
default:
return "";
}
};
const handleTusResponse = async (res: HttpResponse) => {
const fileInfo = res.getHeader("X-File-Info");
if (
typeof fileInfo !== "string" ||
fileInfo === undefined ||
fileInfo === null
)
return;
const { atflCd, atflId } = JSON.parse(fileInfo) ?? {};
if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd);
if (atflId) {
let imageUrl = "";
if (bbsInfo?.bbsTypeCd === "BBSIMAGE") {
imageUrl = await AttachmentApi.getImageUrlByAtflId(atflId);
}
if (bbsInfo?.bbsTypeCd === "BBSMOVIE") {
imageUrl = await AttachmentApi.getMovieThumbnailUrlByAtflId(atflId);
}
setThumbnailUrl(imageUrl);
}
};
const handleProgress = (percentage: string) => {
setProgress(parseInt(percentage));
};
const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) {
alert("첨부파일 최대 개수를 초과했습니다.");
return;
}
for (const file of files) {
const fileSize = Math.round(file.size / 1024); // Byte -> KB
if (bbsInfo?.atflMaxFileSz && fileSize > bbsInfo.atflMaxFileSz) {
alert("첨부파일 최대 크기를 초과했습니다.");
return;
}
setCookie("atfl-cd-key", "");
handleTusUpload(file, {
handleResponse: handleTusResponse,
handleProgress,
});
}
};
useEffect(() => {
if (!attachmentData || attachmentData.length === 0) return;
(async () => {
const imageUrl = await AttachmentApi.getImageUrlByAtflId(
attachmentData[0]?.atflId as string,
);
setThumbnailUrl(imageUrl);
})();
}, [attachmentData]);
return (
<FileUploader
id="atflCd"
className="!h-auto !max-w-none flex-col !items-start gap-4 [&_.file-uploader-label]:h-[3.2rem]"
accept={fileAccept(bbsInfo?.bbsTypeCd as BbsType)}
onUpload={onUpload}
customPreview={
<Label htmlFor="atflCd">
<div className="relative h-[27rem] w-[48rem] overflow-hidden rounded-sm border border-border bg-accent">
<div className="relative h-full w-full">
{thumbnailUrl && (
<Image
src={thumbnailUrl}
className="object-contain"
fill
alt="업로드된 이미지"
/>
)}
</div>
<Progress
value={progress}
className="absolute bottom-0 left-0 z-10"
/>
</div>
</Label>
}
/>
);
}
export { WithPreviewFileUploader };