Merge commit '16541d6d54b044ec5f6bb60db6ef101401cb81ae' into iipbs/wr/form_fuction

This commit is contained in:
leedj312
2025-05-20 18:29:53 +09:00
277 changed files with 6242 additions and 831 deletions
+11 -1
View File
@@ -1,5 +1,7 @@
"use client";
import { DndProvider } from "react-dnd";
import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { AuthWrapper } from "@/entities/auth";
@@ -7,15 +9,23 @@ import { MenuWrapper } from "@/entities/menu";
import { StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { initializeNextClientConfig } from "@common/config";
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
initializeNextClientConfig();
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>{children}</MenuWrapper>
<MenuWrapper>
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
{children}
</DndProvider>
</MenuWrapper>
<AlertDialog />
<ConfirmDialog />
</AuthWrapper>
+4
View File
@@ -1 +1,5 @@
export { AttachmentApi } from "./api/attachment-service";
export type {
AttachmentParams,
Attachment,
} from "./model/attachment-interface";
@@ -1,12 +1,24 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
Board,
BoardFormData,
BoardList,
BoardParams,
BoardPopupList,
BoardPopupParams,
BoardService,
} from "../model/board-interface";
export class BoardApiService implements BoardService {
public async getBoardList(params: BoardParams): Promise<BoardList> {
const response = await client.get<BoardList>(
`${ENDPOINT.BOARD_SERVICE.BBS}`,
{ params },
);
return response.data;
}
public async getBoardPopupList(
params: BoardPopupParams,
): Promise<BoardPopupList> {
@@ -16,6 +28,33 @@ export class BoardApiService implements BoardService {
);
return response.data;
}
public async getBoardInfo(bbsId: string): Promise<Board> {
const response = await client.get<Board>(
`${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`,
);
return response.data;
}
public async createBoard(data: BoardFormData): Promise<Board> {
const response = await client.post<Board>(
`${ENDPOINT.BOARD_SERVICE.BBS}`,
data,
);
return response.data;
}
public async updateBoard(bbsId: string, data: BoardFormData): Promise<Board> {
const response = await client.put<Board>(
`${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`,
data,
);
return response.data;
}
public async deleteBoard(bbsId: string): Promise<void> {
await client.delete(`${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`);
}
}
export const BoardApi = new BoardApiService();
+7 -1
View File
@@ -1,2 +1,8 @@
export { BoardApi } from "./api/board-service";
export { type BoardPopup } from "./model/board-interface";
export {
boardFormSchema,
type Board,
type BoardFormData,
type BoardParams,
type BoardPopup,
} from "./model/board-interface";
@@ -1,3 +1,4 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export const BOARD_TYPE = {
@@ -9,13 +10,34 @@ export const BOARD_TYPE = {
export type BoardType = (typeof BOARD_TYPE)[keyof typeof BOARD_TYPE];
export interface BoardParams extends PageableParams {
keywordType?: string;
keyword?: string;
bbsId?: number;
bbsTypeCd?: string;
bbsNm?: string;
bbsExpln?: string;
rfrncUseYn?: string;
atflUseYn?: string;
atflMaxCnt?: number;
atflMaxFileSz?: number;
ansUseYn?: string;
cmntUseYn?: string;
dgstfnEvlUseYn?: string;
smsUseYn?: string;
upndFixUseYn?: string;
pstgPrdUseYn?: string;
popupUseYn?: string;
delYn?: string;
}
export interface BoardPopupParams extends PageableParams {
bbsNm?: string;
}
export interface BoardPopup {
export interface Board {
bbsId: number;
bbsTypeCd: BoardType;
bbsTypeCd: string;
bbsTypeNm: string;
bbsNm: string;
bbsExpln: string;
@@ -24,6 +46,7 @@ export interface BoardPopup {
atflMaxCnt: number;
atflMaxFileSz: number;
ansUseYn: string;
ansUseAuthrt: string;
cmntUseYn: string;
dgstfnEvlUseYn: string;
smsUseYn: string;
@@ -31,12 +54,43 @@ export interface BoardPopup {
pstgPrdUseYn: string;
popupUseYn: string;
delYn: string;
regDt: string;
useYn: string;
mdfcnDt: string;
mdfrNm: string;
}
export interface BoardPopup extends Board {
pstCnt: number;
}
export const boardFormSchema = z.object({
bbsTypeCd: z.string(),
bbsNm: z.string(),
bbsExpln: z.string(),
rfrncUseYn: z.string(),
atflUseYn: z.string(),
atflMaxCnt: z.number().nullish(),
atflMaxFileSz: z.number().nullish(),
ansUseYn: z.string(),
ansUseAuthrt: z.string().nullish(),
cmntUseYn: z.string(),
dgstfnEvlUseYn: z.string(),
smsUseYn: z.string(),
upndFixUseYn: z.string(),
pstgPrdUseYn: z.string(),
popupUseYn: z.string(),
useYn: z.string(),
});
export type BoardList = PageableList<Board>;
export type BoardPopupList = PageableList<BoardPopup>;
export type BoardFormData = z.infer<typeof boardFormSchema>;
export interface BoardService {
getBoardList: (params: BoardParams) => Promise<BoardList>;
getBoardPopupList: (params: BoardPopupParams) => Promise<BoardPopupList>;
getBoardInfo: (bbsId: string) => Promise<Board>;
createBoard: (data: BoardFormData) => Promise<Board>;
updateBoard: (bbsId: string, data: BoardFormData) => Promise<Board>;
deleteBoard: (bbsId: string) => Promise<void>;
}
+2
View File
@@ -1 +1,3 @@
export { CodeApi } from "./api/code-service";
export type { ICode } from "./model/code-interface";
export { CodeSelect } from "./ui/code-select";
@@ -2,6 +2,7 @@ export interface ICode {
lwpoCd: string;
upCd: string;
lwpoCdNm: string;
unit: string;
}
export interface CodeService {
@@ -0,0 +1,90 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
ContentsService,
ContentsParams,
ContentsList,
ContentsFormData,
ContentsVersionParams,
ContentsVersionList,
ContentsDetail,
} from "../model/contents-interface";
export class ContentsApiService implements ContentsService {
public async getContentsList(params: ContentsParams): Promise<ContentsList> {
const response = await client.get<ContentsList>(
`${ENDPOINT.CMS_SERVICE.CONTENTS}`,
{ params },
);
return response.data;
}
public async getContentsVersionList(
params: ContentsVersionParams,
): Promise<ContentsVersionList> {
const response = await client.get<ContentsVersionList>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`,
{ params },
);
return response.data;
}
public async getLatestContentsInfo(
contsSn: number,
contsVer: number,
): Promise<ContentsDetail> {
const response = await client.get<ContentsDetail>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
);
return response.data;
}
public async createContents(data: ContentsFormData): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`,
data,
);
return response.data;
}
public async updateContents(
contsSn: number,
contsVer: number,
data: ContentsFormData,
): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
data,
);
return response.data;
}
public async deleteContents(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.delete(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
);
}
public async requestApprove(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.put(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approveRequest`,
);
}
public async approveContents(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.put(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approve`,
);
}
}
export const ContentsApi = new ContentsApiService();
+8
View File
@@ -0,0 +1,8 @@
export { ContentsApi } from "./api/contents-service";
export {
CONTENTS_APPROVE_STATUS,
contentsFormSchema,
type Contents,
type ContentsParams,
type ContentsFormData,
} from "./model/contents-interface";
@@ -0,0 +1,100 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export const CONTENTS_APPROVE_STATUS = {
DRAFT: "01",
REQUESTED: "02",
APPROVED: "99",
} as const;
export type ContentsApproveStatus =
(typeof CONTENTS_APPROVE_STATUS)[keyof typeof CONTENTS_APPROVE_STATUS];
export interface ContentsParams extends PageableParams {
contsTtl?: string;
aprvContsVer?: number;
useYn?: string;
}
export interface ContentsVersionParams extends PageableParams {
contsSn?: number;
contsCn?: string;
aprvCd?: ContentsApproveStatus;
cclCd?: string;
koglCd?: string;
tag?: string;
useYn?: string;
}
export interface ContentsVersion {
contsSn: number;
contsVer: number;
contsCn: string;
useYn: string;
}
export const contentsSchema = z.object({
contsSn: z.number(),
contsTtl: z.string(),
aprvContsVer: z.number(),
useYn: z.string(),
mdfcnDt: z.string(),
mdfr: z.string(),
mdfrNm: z.string(),
aprvDt: z.string(),
autzrNm: z.string(),
workAprvCd: z.string(),
workAprvNm: z.string(),
workContsVer: z.number(),
});
export const contentsDetailSchema = z.object({
contsSn: z.number(),
contsVer: z.number(),
contsTtl: z.string(),
contsCn: z.string(),
contsRmrk: z.string().nullish(),
aprvCd: z.enum([
CONTENTS_APPROVE_STATUS.DRAFT,
CONTENTS_APPROVE_STATUS.REQUESTED,
CONTENTS_APPROVE_STATUS.APPROVED,
]),
cclCd: z.string().nullish(),
koglCd: z.string().nullish(),
tag: z.string().nullish(),
aprvDt: z.string(),
autzr: z.string(),
useYn: z.string(),
});
export const contentsFormSchema = contentsDetailSchema.omit({
contsVer: true,
aprvDt: true,
autzr: true,
});
export type Contents = z.infer<typeof contentsSchema>;
export type ContentsDetail = z.infer<typeof contentsDetailSchema>;
export type ContentsFormData = z.infer<typeof contentsFormSchema>;
export type ContentsList = PageableList<Contents>;
export type ContentsVersionList = PageableList<ContentsVersion>;
export interface ContentsService {
getContentsList(params: ContentsParams): Promise<ContentsList>;
getContentsVersionList(
params: ContentsVersionParams,
): Promise<ContentsVersionList>;
getLatestContentsInfo(
contsSn: number,
contsVer: number,
): Promise<ContentsDetail>;
createContents(data: ContentsFormData): Promise<void>;
updateContents(
contsSn: number,
contsVer: number,
data: ContentsFormData,
): Promise<void>;
deleteContents(contsSn: number, contsVer: number): Promise<void>;
requestApprove(contsSn: number, contsVer: number): Promise<void>;
approveContents(contsSn: number, contsVer: number): Promise<void>;
}
@@ -1,51 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
ContentsService,
ContentsParams,
ContentsList,
Contents,
ContentsFormData,
} from "../model/contents-interface";
export class ContentsApiService implements ContentsService {
public async getContentsList(params: ContentsParams): Promise<ContentsList> {
const response = await client.get<ContentsList>(
`${ENDPOINT.CMS_SERVICE.CONTENTS}`,
{ params },
);
return response.data;
}
public async getContentsInfo(contsSn: number): Promise<Contents> {
const response = await client.get<Contents>(
`${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}`,
);
return response.data;
}
public async createContents(data: ContentsFormData): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.CONTENTS}`,
data,
);
return response.data;
}
public async updateContents(
contsSn: number,
data: ContentsFormData,
): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}`,
data,
);
return response.data;
}
public async deleteContents(contsSn: number): Promise<void> {
await client.delete(`${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}`);
}
}
export const ContentsApi = new ContentsApiService();
-2
View File
@@ -1,2 +0,0 @@
export { ContentsApi } from "./api/contents-service";
export type { Contents, ContentsParams } from "./model/contents-interface";
@@ -1,37 +0,0 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface ContentsParams extends PageableParams {
contsTtl?: string;
aprvContsVer?: number;
useYn?: string;
}
export const contentsSchema = z.object({
contsSn: z.number(),
contsTtl: z.string(),
aprvContsVer: z.number(),
useYn: z.string(),
regDt: z.string(),
mdfcnDt: z.string(),
rgTrNm: z.string(),
mdfrNm: z.string(),
});
export const contentsFormSchema = z.object({
contsTtl: z.string(),
useYn: z.string(),
rgtr: z.string().nullish(),
});
export type Contents = z.infer<typeof contentsSchema>;
export type ContentsFormData = z.infer<typeof contentsFormSchema>;
export type ContentsList = PageableList<Contents>;
export interface ContentsService {
getContentsList(params: ContentsParams): Promise<ContentsList>;
getContentsInfo(contsSn: number): Promise<Contents>;
createContents(data: ContentsFormData): Promise<void>;
updateContents(contsSn: number, data: ContentsFormData): Promise<void>;
deleteContents(contsSn: number): Promise<void>;
}
@@ -49,6 +49,7 @@ const Editor = ({ ...props }: IAllProps) => {
}}
init={{
...baseConfig.init,
disabled: props.disabled,
images_upload_handler: imagesUploadHandler,
file_picker_callback: filePickerCallback,
}}
@@ -3,7 +3,7 @@ import { ENDPOINT } from "@/shared/config";
import type {
MenuAuthorityList,
MenuAuthorityParams,
MenuAuthorityPayloadData,
MenuAuthorityPayload,
MenuAuthorityService,
} from "../model/menu-authority-interface";
@@ -18,9 +18,7 @@ export class MenuAuthorityApiService implements MenuAuthorityService {
return response.data;
}
public async saveMenuAuthority(
data: MenuAuthorityPayloadData[],
): Promise<void> {
public async saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`,
data,
@@ -30,12 +30,10 @@ export const menuAuthorityFormSchema = z.object({
export type MenuAuthority = z.infer<typeof menuAuthoritySchema>;
export type MenuAuthorityList = PageableList<MenuAuthority>;
export type MenuAuthorityPayloadData = z.infer<
typeof menuAuthorityPayloadSchema
>;
export type MenuAuthorityPayload = z.infer<typeof menuAuthorityPayloadSchema>;
export type MenuAuthorityFormData = z.infer<typeof menuAuthorityFormSchema>;
export interface MenuAuthorityService {
getMenuAuthorityList(params: MenuAuthorityParams): Promise<MenuAuthorityList>;
saveMenuAuthority(data: MenuAuthorityPayloadData[]): Promise<void>;
saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void>;
}
@@ -1,10 +1,10 @@
import type { ColumnDef } from "@tanstack/react-table";
import type { UseFormReturn } from "react-hook-form";
import { Checkbox } from "@common/components/ui";
import { Checkbox } from "@common/components";
import type {
MenuAuthority,
MenuAuthorityFormData,
MenuAuthorityPayloadData,
MenuAuthorityPayload,
} from "./menu-authority-interface";
export const createColumns = (
@@ -13,7 +13,7 @@ export const createColumns = (
const getCurrentRow = (i: number) => form.watch("menuAuthority")[i];
const handleCheckedChange =
(i: number, key: keyof MenuAuthorityPayloadData) => (checked: boolean) => {
(i: number, key: keyof MenuAuthorityPayload) => (checked: boolean) => {
if (getCurrentRow(i)?.mode === "R") {
form.setValue(`menuAuthority.${i}.mode`, "U");
}
@@ -13,13 +13,13 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@common/components/ui";
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import {
menuAuthorityFormSchema,
type MenuAuthorityFormData,
type MenuAuthorityPayloadData,
type MenuAuthorityPayload,
} from "../model/menu-authority-interface";
import { createColumns } from "../model/menu-authority-table-columns";
import { MenuAuthorityApi } from "../api/menu-authority-service";
@@ -42,7 +42,7 @@ function ManagementAuthorityDialog({
});
const { mutateAsync: saveMenuAuthority } = useMutation({
mutationFn: (data: MenuAuthorityPayloadData[]) =>
mutationFn: (data: MenuAuthorityPayload[]) =>
MenuAuthorityApi.saveMenuAuthority(data),
onSuccess: () => {
alert("저장되었습니다.");
@@ -1,4 +1,4 @@
import { client } from "@common/components/api";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { MenuItem, MenuService } from "../model/menu-interface";
+1
View File
@@ -1 +1,2 @@
export { MenuWrapper } from "./lib/menu-wrapper";
export type { MenuItem } from "./model/menu-interface";
@@ -0,0 +1,66 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
type Organization,
type OrganizationFormData,
type OrganizationService,
type OrganizationTree,
type OrganizationTreeForDnd,
} from "../model/organization-interface";
export class OrganizationApiService implements OrganizationService {
public async getOrganizationsTreeList(): Promise<OrganizationTree[]> {
const response = await client.get<OrganizationTree[]>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/tree`,
);
return response.data;
}
public async getOrganizationInfo(ognzSn: number): Promise<Organization> {
const response = await client.get<Organization>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}`,
);
return response.data;
}
public async createOrganization(data: OrganizationFormData): Promise<void> {
await client.post<void>(`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}`, data);
}
public async deleteOrganization(ognzSn: number): Promise<void> {
await client.delete<void>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}`,
);
}
public async updateOrganizationName(
ognzSn: number,
name: string,
): Promise<Organization> {
const response = await client.put<Organization>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}/${name}`,
);
return response.data;
}
public async updateOrganization(
ognzSn: number,
data: OrganizationFormData,
): Promise<void> {
await client.put<void>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}`,
data,
);
}
public async updateOrganizationByDnd(
data: OrganizationTreeForDnd[],
): Promise<void> {
await client.put<void>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/dnd/tree`,
data,
);
}
}
export const OrganizationApi = new OrganizationApiService();
@@ -0,0 +1,8 @@
export { OrganizationApi } from "./api/organization-service";
export {
organizationFormSchema,
type OrganizationTree,
type OrganizationFormData,
type OrganizationTreeForDnd,
} from "./model/organization-interface";
export { generateOrganizationTreeData, handleDropNode } from "./lib/utils";
@@ -0,0 +1,78 @@
import type { DropOptions, NodeModel } from "@minoru/react-dnd-treeview";
import type {
OrganizationTree,
OrganizationTreeForDnd,
} from "../model/organization-interface";
export const generateOrganizationTreeData = (
data: OrganizationTree[],
): NodeModel<OrganizationTree>[] => {
return data.reduce<NodeModel<OrganizationTree>[]>((acc, menu) => {
const node: NodeModel<OrganizationTree> = {
id: menu.ognzSn ?? 0,
parent: menu.upOgnzSn ?? 0,
text: menu.ognzNm,
droppable: true,
data: menu,
};
acc.push(node);
if (menu.children?.length) {
acc.push(...generateOrganizationTreeData(menu.children));
}
return acc;
}, []);
};
export const generateDndOrganizationTreeData = (
treeData: NodeModel<OrganizationTree>[],
options: DropOptions<OrganizationTree>,
): OrganizationTreeForDnd | null => {
const { dragSourceId, dragSource, dropTargetId } = options;
if (!dragSource?.data) return null;
const siblings = treeData.filter((node) => node.parent === dropTargetId);
const dragSourceIndex = siblings.findIndex(
(node) => node.id === dragSourceId,
);
const prevSibling = siblings[dragSourceIndex - 1];
const newSortSeq =
dragSourceIndex === 0 ? 1 : (prevSibling?.data?.sortSeq ?? 0) + 1;
const treeNode: OrganizationTreeForDnd = {
ognzSn: dragSource.data.ognzSn,
ognzNm: dragSource.data.ognzNm,
ognzExpln: dragSource.data.ognzExpln,
upOgnzSn: dropTargetId === 0 ? null : (dropTargetId as number),
sortSeq: newSortSeq,
};
const childNodes = treeData.filter(
(node) => node.parent === dragSourceId && node.data,
);
const result: OrganizationTreeForDnd = {
...treeNode,
children:
childNodes?.map((child) => ({
ognzSn: child.data?.ognzSn,
ognzNm: child.data?.ognzNm ?? "",
ognzExpln: child.data?.ognzExpln,
upOgnzSn: child.data?.upOgnzSn === 0 ? null : (dragSourceId as number),
sortSeq: child.data?.sortSeq ?? 1,
})) ?? [],
};
return result;
};
export const handleDropNode =
(callback: (newTree: OrganizationTreeForDnd) => void) =>
(
newTree: NodeModel<OrganizationTree>[],
options: DropOptions<OrganizationTree>,
) => {
const newTreeData = generateDndOrganizationTreeData(newTree, options);
if (!newTreeData) return;
callback(newTreeData);
};
@@ -0,0 +1,31 @@
import { z } from "zod";
export const organizationSchema = z.object({
ognzSn: z.number().nullish(),
ognzNm: z.string(),
ognzExpln: z.string().nullish(),
upOgnzSn: z.number().nullish(),
sortSeq: z.number(),
useYn: z.string().nullish(),
});
export const organizationFormSchema = organizationSchema.omit({
ognzSn: true,
});
export type Organization = z.infer<typeof organizationSchema>;
export type OrganizationFormData = z.infer<typeof organizationFormSchema>;
export type OrganizationTree = Organization & {
children?: OrganizationTree[];
};
export type OrganizationTreeForDnd = Omit<OrganizationTree, "useYn">;
export interface OrganizationService {
getOrganizationsTreeList(): Promise<OrganizationTree[]>;
getOrganizationInfo(ognzSn: number): Promise<Organization>;
createOrganization(data: OrganizationFormData): Promise<void>;
deleteOrganization(ognzSn: number): Promise<void>;
updateOrganizationName(ognzSn: number, name: string): Promise<Organization>;
updateOrganization(ognzSn: number, data: OrganizationFormData): Promise<void>;
updateOrganizationByDnd(data: OrganizationTreeForDnd[]): Promise<void>;
}
@@ -5,7 +5,7 @@ import type {
PostService,
PostingParams,
Post,
PostPayloadData,
PostPayload,
} from "../model/posting-interface";
export class PostingApiService implements PostService {
@@ -19,7 +19,7 @@ export class PostingApiService implements PostService {
return response.data;
}
public async savePosting(data: PostPayloadData[]): Promise<Post[]> {
public async savePosting(data: PostPayload[]): Promise<Post[]> {
const response = await client.post<Post[]>(
`${ENDPOINT.CMS_SERVICE.POSTINGS}`,
data,
+1 -1
View File
@@ -2,6 +2,6 @@ export { PostingApi } from "./api/posting-service";
export {
POSTING_TYPE,
type Post,
type PostPayloadData,
type PostPayload,
} from "./model/posting-interface";
export { PostingDialog } from "./ui/posting-dialog";
@@ -41,10 +41,10 @@ export const postingFormSchema = z.object({
});
export type Post = z.infer<typeof postSchema>;
export type PostPayloadData = z.infer<typeof postPayloadSchema>;
export type PostPayload = z.infer<typeof postPayloadSchema>;
export type PostingFormData = z.infer<typeof postingFormSchema>;
export interface PostService {
getPostingHistory: (params: PostingParams) => Promise<PageableList<Post>>;
savePosting: (data: PostPayloadData[]) => Promise<Post[]>;
savePosting: (data: PostPayload[]) => Promise<Post[]>;
}
@@ -23,8 +23,8 @@ import {
DateRangePicker,
DialogClose,
DialogFooter,
} from "@common/components/ui";
import { cn } from "@common/components/lib";
cn,
} from "@common/components";
import { CodeSelect } from "@/entities/code/@x/posting";
import { SiteApi, type Site } from "@/entities/site/@x/posting";
import type { PageableList } from "@/shared/model";
@@ -34,7 +34,7 @@ import {
postingFormSchema,
type PostingFormData,
type PostingType,
type PostPayloadData,
type PostPayload,
} from "../model/posting-interface";
import { useAlertStore } from "@/shared/lib/use-alert-store";
@@ -76,7 +76,7 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) {
});
const { mutateAsync: savePosting } = useMutation({
mutationFn: (data: PostPayloadData[]) => PostingApi.savePosting(data),
mutationFn: (data: PostPayload[]) => PostingApi.savePosting(data),
onSuccess: () => {
alert("저장되었습니다.");
},
@@ -0,0 +1,62 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
TreeUserOrganization,
UserOrganizationParams,
UserOrganizationList,
UserOrganizationService,
UserOrganizationPayload,
SearchUserParams,
SearchUserList,
User,
MoveUserOrganizationPayload,
} from "../model/user-organization-interface";
export class UserOrganizationApiService implements UserOrganizationService {
public async getUserOrganizationList(
params: UserOrganizationParams,
): Promise<UserOrganizationList> {
const response = await client.get<UserOrganizationList>(
`${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}`,
{ params },
);
return response.data;
}
public async getUserOrganizationTreeList(): Promise<TreeUserOrganization[]> {
const response = await client.get<TreeUserOrganization[]>(
`${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/treeuser`,
);
return response.data;
}
public async searchUser(params: SearchUserParams): Promise<SearchUserList> {
const response = await client.get<SearchUserList>(
`${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/user-popup`,
{ params },
);
return response.data;
}
public async saveUserOrganization(
data: UserOrganizationPayload[],
): Promise<User[]> {
const response = await client.post<User[]>(
`${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}`,
data,
);
return response.data;
}
public async moveUserOrganization(
data: MoveUserOrganizationPayload[],
): Promise<User[]> {
const response = await client.post<User[]>(
`${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/move-ognz`,
data,
);
return response.data;
}
}
export const UserOrganizationApi = new UserOrganizationApiService();
@@ -0,0 +1,11 @@
export { UserOrganizationApi } from "./api/user-organization-service";
export {
userOrganizationFormSchema,
type TreeUserOrganization,
type TreeUser,
type UserOrganizationFormData,
type UserOrganizationPayload,
type User,
type SearchUser,
type MoveUserOrganizationPayload,
} from "./model/user-organization-interface";
@@ -0,0 +1,92 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface UserOrganizationParams extends PageableParams {
keywordType?: string;
keyword?: string;
ognzSn?: number;
userUnqId?: string;
jbpsSeCd?: string;
jbgdSeCd?: string;
}
export interface SearchUserParams extends PageableParams {
userNm?: string;
ogdpSeCd?: string;
}
export interface User {
mode: string;
ognzSn: number;
userUnqId: string;
userUnqNm?: string;
jbpsSeCd: string;
jbpsSeNm?: string;
jbgdSeCd: string;
jbgdSeNm?: string;
sortSeq: number;
mdfcnDt?: string;
mdfr?: string;
mdfrNm?: string;
}
export interface SearchUser {
userUnqId: string;
userNm: string;
ogdpSeCd: string;
ogdpSeNm: string;
regDt: string;
}
export interface TreeUser {
userUnqId: string;
userNm: string;
jbpsSeCd: string;
jbgdSeCd: string;
}
export interface TreeUserOrganization {
ognzSn: number;
upOgnzSn: number;
ognzNm: string;
users: TreeUser[];
children: TreeUserOrganization[];
}
export interface MoveUserOrganizationPayload {
ognzSn: number;
toOgnzSn: number;
userUnqId: string;
}
export const userOrganizationPayloadSchema = z.object({
mode: z.string(),
ognzSn: z.number(),
userUnqId: z.string(),
jbpsSeCd: z.string(),
jbgdSeCd: z.string(),
sortSeq: z.number(),
});
export const userOrganizationFormSchema = z.object({
userOrganization: z.array(userOrganizationPayloadSchema),
});
export type UserOrganizationList = PageableList<User>;
export type SearchUserList = PageableList<SearchUser>;
export type UserOrganizationPayload = z.infer<
typeof userOrganizationPayloadSchema
>;
export type UserOrganizationFormData = z.infer<
typeof userOrganizationFormSchema
>;
export interface UserOrganizationService {
getUserOrganizationList(
params: UserOrganizationParams,
): Promise<UserOrganizationList>;
getUserOrganizationTreeList(): Promise<TreeUserOrganization[]>;
searchUser(params: SearchUserParams): Promise<SearchUserList>;
saveUserOrganization(data: UserOrganizationPayload[]): Promise<User[]>;
moveUserOrganization(data: MoveUserOrganizationPayload[]): Promise<User[]>;
}
@@ -4,7 +4,7 @@ import dayjs from "dayjs";
import Image from "next/image";
import { useQuery } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox, Skeleton } from "@common/components/ui";
import { Checkbox, Skeleton } from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import { ENDPOINT } from "@/shared/config";
import type { Banner } from "./banner-interface";
@@ -2,7 +2,7 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import { Checkbox } from "@common/components";
import type { Post } from "@/entities/posting";
export const columns: ColumnDef<Post>[] = [
+3 -3
View File
@@ -11,8 +11,8 @@ import {
type RowSelectionState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button, DataTable } from "@common/components/ui";
import { PostingApi, type PostPayloadData } from "@/entities/posting";
import { Button, DataTable } from "@common/components";
import { PostingApi, type PostPayload } from "@/entities/posting";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ENDPOINT } from "@/shared/config";
import {
@@ -58,7 +58,7 @@ function ModifyPage({ sequential }: { sequential: string }) {
});
const { mutateAsync: savePosting } = useMutation({
mutationFn: (data: PostPayloadData[]) => PostingApi.savePosting(data),
mutationFn: (data: PostPayload[]) => PostingApi.savePosting(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, sequential],
+3
View File
@@ -0,0 +1,3 @@
export { BbsPage } from "./ui/bbs-page";
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
@@ -0,0 +1,61 @@
import dayjs from "dayjs";
import { useRouter } from "next/navigation";
import type { ColumnDef, Row } from "@tanstack/react-table";
import { useLocalStorage } from "@frontend-opensource/use-react-hooks";
import type { Board } from "@/entities/board/model/board-interface";
import { Button } from "@common/components";
export const columns: ColumnDef<Board>[] = [
{
id: "no",
header: "NO",
cell: ({ row }) => row.index + 1,
},
{
accessorKey: "bbsNm",
header: "게시판명",
},
{
accessorKey: "bbsTypeNm",
header: "게시판유형",
},
{
accessorKey: "mdfrNm",
header: "최종수정자",
},
{
accessorKey: "mdfcnDt",
header: "최종수정일",
cell: ({ row }) =>
row.original.mdfcnDt
? dayjs(row.original.mdfcnDt).format("YYYY-MM-DD")
: "",
},
{
accessorKey: "useYn",
header: "사용여부",
},
{
id: "actions",
header: "관리",
cell: ({ row }) => <ManageButton row={row} />,
},
];
const ManageButton = ({ row }: { row: Row<Board> }) => {
const router = useRouter();
const { bbsTypeCd, bbsId } = row.original;
const [, setBbsInfo] = useLocalStorage("bbs-info", "");
return (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
router.push(`/pst/${bbsTypeCd.toLowerCase()}/${bbsId}`);
setBbsInfo(JSON.stringify(row.original));
}}
>
관리
</Button>
);
};
+447
View File
@@ -0,0 +1,447 @@
"use client";
import type { UseFormReturn } from "react-hook-form";
import {
Input,
Label,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { FormWrapper } from "@/widgets/form-wrapper";
import { CodeSelect } from "@/entities/code";
import type { Board, BoardFormData } from "@/entities/board";
import { FormControl, FormField, FormItem } from "@/shared/ui/form";
type BbsFormProps = {
form: UseFormReturn<BoardFormData>;
bbsId?: string;
handleSave: (data: BoardFormData) => Promise<Board>;
};
function BbsForm({ form, bbsId, ...props }: BbsFormProps) {
return (
<FormWrapper form={form} isModifyMode={!!bbsId} {...props}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="85%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>게시판명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bbsNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="게시판명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>게시판 설명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bbsExpln"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="게시판설명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>게시판 유형</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bbsTypeCd"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormControl>
<CodeSelect
{...field}
upCd="BBSTYPE"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>참조 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="rfrncUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="rfrncUseY" />
<Label htmlFor="rfrncUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="rfrncUseN" />
<Label htmlFor="rfrncUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="atflUseY" />
<Label htmlFor="atflUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="atflUseN" />
<Label htmlFor="atflUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일 최대 개수</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflMaxCnt"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="number"
min={0}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (isNaN(Number(value))) return;
field.onChange(value === "" ? null : Number(value));
}}
className="w-[12rem] !text-[1.3rem]"
placeholder="최대 개수 입력"
disabled={form.watch("atflUseYn") === "N"}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일 최대 크기</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflMaxFileSz"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="number"
min={0}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (isNaN(Number(value))) return;
field.onChange(value === "" ? null : Number(value));
}}
className="w-[12rem] !text-[1.3rem]"
placeholder="최대 크기 입력"
disabled={form.watch("atflUseYn") === "N"}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>답글 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="ansUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="ansUseY" />
<Label htmlFor="ansUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="ansUseN" />
<Label htmlFor="ansUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>답글 사용 권한</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="ansUseAuthrt"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={form.watch("ansUseYn") === "N"}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">관리자</SelectItem>
<SelectItem value="M">회원</SelectItem>
<SelectItem value="N">일반</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>댓글 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="cmntUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="cmntUseY" />
<Label htmlFor="cmntUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="cmntUseN" />
<Label htmlFor="cmntUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>게시물평가 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="dgstfnEvlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="dgstfnEvlUseY" />
<Label htmlFor="dgstfnEvlUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="dgstfnEvlUseN" />
<Label htmlFor="dgstfnEvlUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>팝업 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="popupUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="popupUseY" />
<Label htmlFor="popupUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="popupUseN" />
<Label htmlFor="popupUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>SMS알림 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="smsUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="smsUseY" />
<Label htmlFor="smsUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="smsUseN" />
<Label htmlFor="smsUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>상단고정 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="upndFixUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="upndFixUseY" />
<Label htmlFor="upndFixUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="upndFixUseN" />
<Label htmlFor="upndFixUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>게시기간 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="pstgPrdUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="pstgPrdUseY" />
<Label htmlFor="pstgPrdUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="pstgPrdUseN" />
<Label htmlFor="pstgPrdUseN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</FormWrapper>
);
}
export { BbsForm };
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import {
Button,
DataTable,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import { BoardApi, type BoardParams } from "@/entities/board";
import { ENDPOINT } from "@/shared/config";
import { type Board } from "@/entities/board";
import { columns } from "../model/table-columns";
function BbsPage() {
const router = useRouter();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [boardParams, setBoardParams] = useState<BoardParams>({
bbsTypeCd: "",
bbsNm: "",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: boardList, refetch } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.BBS],
queryFn: () =>
BoardApi.getBoardList({
...boardParams,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
enabled: false,
});
const table = useReactTable({
data: boardList?.content ?? [],
columns,
state: {
pagination,
rowSelection,
},
rowCount: boardList?.totalElements ?? 0,
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
const handleRowClick = (row: Board) => {
router.push(`/bbs/register/${row.bbsId}`);
};
useEffect(() => {
refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="bbsType" className="mr-2 text-label">
게시판 유형
</Label>
<CodeSelect
upCd="BBSTYPE"
value={boardParams.bbsTypeCd}
onValueChange={(value) =>
setBoardParams({ ...boardParams, bbsTypeCd: value })
}
/>
<Label htmlFor="bbsNm" className="ml-4 mr-2 text-label">
게시판명
</Label>
<Input
id="bbsNm"
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={boardParams.bbsNm}
onChange={(e) =>
setBoardParams({ ...boardParams, bbsNm: e.target.value })
}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<div className="flex w-full items-center justify-between">
<h3 className="text-2xl font-medium text-label">게시판 목록</h3>
<div className="flex gap-2">
<Button onClick={() => router.push("/bbs/register")}>등록</Button>
<Select
value={table.getState().pagination.pageSize.toString()}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-[3.2rem] w-[12rem]">
<SelectValue placeholder="페이지 크기" />
</SelectTrigger>
<SelectContent>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={pageSize.toString()}>
{pageSize}개씩 보기
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} viewPagination />
</div>
</div>
</div>
);
}
export { BbsPage };
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import {
BoardApi,
boardFormSchema,
type BoardFormData,
} from "@/entities/board";
import { BbsForm } from "./bbs-form";
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createBoard } = useMutation({
mutationFn: (data: BoardFormData) => BoardApi.createBoard(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<BoardFormData>({
resolver: zodResolver(boardFormSchema),
defaultValues: {
bbsTypeCd: "",
bbsNm: "",
bbsExpln: "",
rfrncUseYn: "Y",
atflUseYn: "Y",
ansUseYn: "Y",
cmntUseYn: "Y",
dgstfnEvlUseYn: "Y",
smsUseYn: "Y",
upndFixUseYn: "Y",
pstgPrdUseYn: "Y",
popupUseYn: "Y",
useYn: "Y",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">게시판 등록</h3>
</div>
<BbsForm form={form} handleSave={createBoard} />
</div>
);
}
export { CreatePage };
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ENDPOINT } from "@/shared/config";
import {
BoardApi,
boardFormSchema,
type BoardFormData,
} from "@/entities/board";
import { BbsForm } from "./bbs-form";
function ModifyPage({ bbsId }: { bbsId: string }) {
const { setMessage: alert } = useAlertStore((state) => state);
const { data: boardInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.BBS, bbsId],
queryFn: () => BoardApi.getBoardInfo(bbsId),
});
const { mutateAsync: updateBoard } = useMutation({
mutationFn: (data: BoardFormData) => BoardApi.updateBoard(bbsId, data),
onSuccess: () => {
alert("수정되었습니다.");
},
});
const form = useForm<BoardFormData>({
resolver: zodResolver(boardFormSchema),
values: boardInfo,
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">게시판 수정</h3>
</div>
<BbsForm form={form} bbsId={bbsId} handleSave={updateBoard} />
</div>
);
}
export { ModifyPage };
+2
View File
@@ -1 +1,3 @@
export { ContentsPage } from "./ui/contents-page";
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
@@ -1,47 +1,68 @@
import dayjs from "dayjs";
import { type ColumnDef } from "@tanstack/react-table";
import type { Contents } from "@/entities/contents";
import { createColumnHelper } from "@tanstack/react-table";
import type { Contents } from "@/entities/content";
export const columns: ColumnDef<Contents>[] = [
{
const columnHelper = createColumnHelper<Contents>();
export const columns = [
columnHelper.display({
id: "no",
header: "NO",
cell: ({ row }) => row.index + 1,
},
{
accessorKey: "contsTtl",
}),
columnHelper.accessor("contsTtl", {
id: "contsTtl",
header: "콘텐츠명",
},
{
accessorKey: "aprvContsVer",
header: "승인여부",
},
{
accessorKey: "rgTrNm",
header: "승인자",
},
{
accessorKey: "regDt",
header: "승인일자",
cell: ({ row }) => {
const date = dayjs(row.original.regDt);
return date.format("YYYY-MM-DD");
},
},
{
accessorKey: "useYn",
}),
columnHelper.group({
id: "workInfo",
header: "작성정보",
columns: [
columnHelper.accessor("workContsVer", {
header: "작성버전",
cell: ({ row }) =>
row.original.workContsVer ? `rev. ${row.original.workContsVer}` : "",
}),
columnHelper.accessor("workAprvNm", {
header: "작성상태",
}),
],
}),
columnHelper.group({
id: "aprvInfo",
header: "승인정보",
columns: [
columnHelper.accessor("aprvContsVer", {
header: "승인버전",
cell: ({ row }) =>
row.original.aprvContsVer ? `rev. ${row.original.aprvContsVer}` : "",
}),
columnHelper.accessor("autzrNm", {
header: "승인자",
}),
columnHelper.accessor("aprvDt", {
header: "승인일자",
cell: ({ row }) =>
row.original.aprvDt
? dayjs(row.original.aprvDt).format("YYYY-MM-DD")
: "",
}),
],
}),
columnHelper.accessor("useYn", {
id: "useYn",
header: "사용여부",
},
{
accessorKey: "mdfrNm",
}),
columnHelper.accessor("mdfrNm", {
id: "mdfrNm",
header: "최종수정자",
},
{
accessorKey: "mdfcnDt",
}),
columnHelper.accessor("mdfcnDt", {
id: "mdfcnDt",
header: "최종수정일",
cell: ({ row }) => {
const date = dayjs(row.original.mdfcnDt);
return date.format("YYYY-MM-DD");
},
},
cell: ({ row }) =>
row.original.mdfcnDt
? dayjs(row.original.mdfcnDt).format("YYYY-MM-DD")
: "",
}),
];
@@ -0,0 +1,268 @@
import dynamic from "next/dynamic";
import { type UseFormReturn } from "react-hook-form";
import {
Input,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { FormWrapper } from "@/widgets/form-wrapper";
import { FormItem, FormControl, FormField } from "@/shared/ui/form";
import type { ContentsFormData } from "@/entities/content";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { CodeApi } from "@/entities/code/api/code-service";
import Image from "next/image";
const Editor = dynamic(
() => import("@/entities/editor").then((module) => module.Editor),
{ ssr: false },
);
interface ContentsFormProps {
form: UseFormReturn<ContentsFormData>;
contsSn?: number;
isEditable?: boolean;
isContsTtlDisabled?: boolean;
handleSave: (data: ContentsFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
}
function ContentsForm({
form,
contsSn,
isEditable,
isContsTtlDisabled,
...props
}: ContentsFormProps) {
const { data: cclCode } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "CCLCD"],
queryFn: () => CodeApi.getCode("CCLCD"),
});
const { data: koglCode } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "KOGLCD"],
queryFn: () => CodeApi.getCode("KOGLCD"),
});
return (
<FormWrapper form={form} isModifyMode={!!contsSn} {...props}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="85%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>콘텐츠 제목</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="contsTtl"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="콘텐츠 제목을 입력하십시오."
disabled={!isEditable || isContsTtlDisabled}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>콘텐츠 내용</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="contsCn"
render={({ field }) => (
<FormItem>
<FormControl>
<Editor
value={field.value ?? ""}
onEditorChange={field.onChange}
disabled={!isEditable}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>CCL</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="cclCd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="cclCd"
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={!isEditable}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="" id="ccl-none" />
<Label
htmlFor="ccl-none"
className="whitespace-nowrap"
>
해당없음
</Label>
</div>
{cclCode?.map((code) => (
<div
className="flex items-center space-x-2"
key={code.lwpoCd}
>
<RadioGroupItem
value={code.lwpoCd}
id={`ccl-${code.lwpoCd}`}
/>
<Label htmlFor={`ccl-${code.lwpoCd}`}>
<Image
src={`/images/${code.unit}`}
alt="CCL"
width={77}
height={27}
/>
</Label>
</div>
))}
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>공공누리</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="koglCd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="koglCd"
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={!isEditable}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="" id="koglNone" />
<Label
htmlFor="koglNone"
className="whitespace-nowrap"
>
해당없음
</Label>
</div>
{koglCode?.map((code) => (
<div
className="flex items-center space-x-2"
key={code.lwpoCd}
>
<RadioGroupItem
value={code.lwpoCd}
id={`kogl-${code.lwpoCd}`}
/>
<Label htmlFor={`kogl-${code.lwpoCd}`}>
<div className="relative h-[2.7rem] w-[11rem]">
<Image
src={`/images/${code.unit}`}
alt="공공누리"
fill
className="object-contain"
/>
</div>
</Label>
</div>
))}
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>태그</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="tag"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="태그를 입력하십시오."
disabled={!isEditable}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={!isEditable}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</FormWrapper>
);
}
export { ContentsForm };
@@ -13,7 +13,7 @@ import {
ContentsApi,
type Contents,
type ContentsParams,
} from "@/entities/contents";
} from "@/entities/content";
import { ENDPOINT } from "@/shared/config";
import { columns } from "../model/table-columns";
@@ -89,8 +89,13 @@ function ContentsPage() {
등록
</Button>
</div>
<div className="card !m-[1.2rem] h-[29.2rem] overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} viewPagination />
<div className="card !m-[1.2rem] overflow-auto !p-0">
<DataTable
table={table}
onRowClick={handleRowClick}
variant="secondary"
viewPagination
/>
</div>
</div>
</div>
@@ -0,0 +1,43 @@
"use client";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import {
ContentsApi,
contentsFormSchema,
type ContentsFormData,
} from "@/entities/content";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ContentsForm } from "./contents-form";
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createContents } = useMutation({
mutationFn: (data: ContentsFormData) => ContentsApi.createContents(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<ContentsFormData>({
resolver: zodResolver(contentsFormSchema),
defaultValues: {
useYn: "Y",
},
});
return (
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">콘텐츠 등록</h3>
</div>
<ContentsForm form={form} handleSave={createContents} />
</div>
);
}
export { CreatePage };
@@ -0,0 +1,182 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import {
CONTENTS_APPROVE_STATUS,
ContentsApi,
contentsFormSchema,
type ContentsFormData,
} from "@/entities/content";
import { ContentsForm } from "./contents-form";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Button,
} from "@common/components";
function ModifyPage({ sequential }: { sequential: string }) {
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const contsSn = Number(sequential);
const [contsVer, setContsVer] = useState(0);
const { data: contentsVersionList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn],
queryFn: () =>
ContentsApi.getContentsVersionList({
contsSn: contsSn,
page: 0,
size: 100,
}),
});
const { data: contentsDetail } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsVer],
queryFn: () => ContentsApi.getLatestContentsInfo(contsSn, contsVer),
enabled: !!contsVer,
});
const { mutateAsync: createContents } = useMutation({
mutationFn: (data: ContentsFormData) => ContentsApi.createContents(data),
onSuccess: () => {
setContsVer(0);
alert("수정되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn],
});
},
});
const { mutateAsync: updateContents } = useMutation({
mutationFn: (data: ContentsFormData) =>
ContentsApi.updateContents(contsSn, contsVer, data),
onSuccess: () => {
alert("수정되었습니다.");
},
});
const { mutateAsync: deleteContents } = useMutation({
mutationFn: () => ContentsApi.deleteContents(contsSn, contsVer),
onSuccess: () => {
setContsVer(0);
alert("삭제되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn],
});
},
});
const { mutateAsync: requestApprove } = useMutation({
mutationFn: () => ContentsApi.requestApprove(contsSn, contsVer),
onSuccess: () => {
alert("승인 요청이 완료되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS],
});
},
});
const { mutateAsync: approveContents } = useMutation({
mutationFn: () => ContentsApi.approveContents(contsSn, contsVer),
onSuccess: () => {
alert("승인되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS],
});
},
});
const form = useForm<ContentsFormData>({
resolver: zodResolver(contentsFormSchema),
values: contentsDetail,
});
const approveStatus = contentsDetail?.aprvCd;
const isContsTtlDisabled =
(contentsVersionList?.content?.[0]?.contsVer ?? 0) > 1;
const onRequestApprove = async () => {
const message =
"승인 요청하면 더이상 내용을 수정할 수 없습니다.\n요청하시겠습니까?";
if (await confirm(message)) requestApprove();
};
const onApprove = async () => {
const message = "승인하시겠습니까?";
if (await confirm(message)) approveContents();
};
useEffect(() => {
if (contsVer) return;
if (!contentsVersionList?.content?.length) return;
const latestContent = contentsVersionList?.content[0];
if (!latestContent) return;
setContsVer(latestContent?.contsVer ?? 0);
}, [contentsVersionList?.content]);
return (
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">템플릿 수정</h3>
<div className="flex items-center gap-2">
<Select
value={contsVer.toString()}
onValueChange={(value) => {
setContsVer(Number(value));
}}
>
<SelectTrigger className="w-[28rem]">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{contentsVersionList?.content?.map((item) => (
<SelectItem
key={item.contsVer}
value={item.contsVer.toString()}
>
{item.contsCn}
</SelectItem>
))}
</SelectContent>
</Select>
{approveStatus === CONTENTS_APPROVE_STATUS.DRAFT && (
<Button onClick={onRequestApprove}>승인요청</Button>
)}
{approveStatus === CONTENTS_APPROVE_STATUS.REQUESTED && (
<>
<Button onClick={onApprove}>승인</Button>
<Button color="gray">반려</Button>
</>
)}
</div>
</div>
<ContentsForm
form={form}
contsSn={contsSn}
isEditable={approveStatus !== CONTENTS_APPROVE_STATUS.REQUESTED}
isContsTtlDisabled={isContsTtlDisabled}
handleSave={
approveStatus === CONTENTS_APPROVE_STATUS.APPROVED
? createContents
: updateContents
}
handleDelete={deleteContents}
/>
</div>
);
}
export { ModifyPage };
+15 -1
View File
@@ -3,7 +3,7 @@
import dayjs from "dayjs";
import { type ColumnDef } from "@tanstack/react-table";
import { Button } from "@common/components";
import type { Attachment } from "@/entities/attachment/model/attachment-interface";
import type { Attachment } from "@/entities/attachment";
export const createColumns = (
handleDeleteAttachment: (atflId: string) => void,
@@ -28,6 +28,10 @@ export const createColumns = (
{
accessorKey: "atflFlieSz",
header: "파일크기",
cell: ({ row }) => {
const size = row.getValue("atflFlieSz");
return size ? formatFileSize(Number(size)) : "0 Bytes";
},
},
{
@@ -60,3 +64,13 @@ export const createColumns = (
),
},
];
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
+1 -2
View File
@@ -9,8 +9,7 @@ import {
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button, DataTable, Input, Label } from "@common/components";
import type { AttachmentParams } from "@/entities/attachment/model/attachment-interface";
import { AttachmentApi } from "@/entities/attachment/api/attachment-service";
import { AttachmentApi, type AttachmentParams } from "@/entities/attachment";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ENDPOINT } from "@/shared/config";
import { createColumns } from "../model/table-columns";
@@ -2,7 +2,7 @@
import dayjs from "dayjs";
import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import { Checkbox } from "@common/components";
import { type Image } from "./image-interface";
import { IMAGE_TYPE } from "./image-interface";
@@ -2,7 +2,7 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import { Checkbox } from "@common/components";
import type { Post } from "@/entities/posting";
export const columns: ColumnDef<Post>[] = [
+3 -3
View File
@@ -11,8 +11,8 @@ import {
type RowSelectionState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button, DataTable } from "@common/components/ui";
import { PostingApi, type PostPayloadData } from "@/entities/posting";
import { Button, DataTable } from "@common/components";
import { PostingApi, type PostPayload } from "@/entities/posting";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ENDPOINT } from "@/shared/config";
import { imageFormSchema, type ImageFormData } from "../model/image-interface";
@@ -54,7 +54,7 @@ function ModifyPage({ sequential }: { sequential: string }) {
});
const { mutateAsync: savePosting } = useMutation({
mutationFn: (data: PostPayloadData[]) => PostingApi.savePosting(data),
mutationFn: (data: PostPayload[]) => PostingApi.savePosting(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, sequential],
@@ -1,17 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
Organization,
OrganizationService,
} from "../model/organization-interface";
export class OrganizationApiService implements OrganizationService {
public async getOrganizationUserTreeList(): Promise<Organization[]> {
const response = await client.get<Organization[]>(
`${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/treeuser`,
);
return response.data;
}
}
export const OrganizationApi = new OrganizationApiService();
+39 -33
View File
@@ -1,6 +1,9 @@
import type { DropOptions, NodeModel } from "@minoru/react-dnd-treeview";
import type {
TreeUserOrganization,
TreeUser,
} from "@/entities/user-organization";
import type { TreeMenu, TreeNode } from "../model/menu-interface";
import type { Organization, User } from "../model/organization-interface";
export const generateMenuTreeData = (
data: TreeMenu[],
@@ -107,44 +110,47 @@ export const handleNodeTextChange =
callback(newTree);
};
export const generateOrganizationTreeData = (
organizations: Organization[],
): NodeModel<Organization | User>[] => {
return organizations.reduce<NodeModel<Organization | User>[]>((acc, org) => {
const orgNode: NodeModel<Organization> = {
id: org.ognzId,
parent: org.upOgnzId ?? 0,
text: org.ognzNm,
data: org,
droppable: false,
};
export const generateUserOrganizationTreeData = (
organizations: TreeUserOrganization[],
): NodeModel<TreeUserOrganization | TreeUser>[] => {
return organizations.reduce<NodeModel<TreeUserOrganization | TreeUser>[]>(
(acc, org) => {
const orgNode: NodeModel<TreeUserOrganization> = {
id: org.ognzSn,
parent: org.upOgnzSn ?? 0,
text: org.ognzNm,
data: org,
droppable: false,
};
acc.push(orgNode);
acc.push(orgNode);
if (org.children?.length) {
acc.push(...generateOrganizationTreeData(org.children));
}
if (org.children?.length) {
acc.push(...generateUserOrganizationTreeData(org.children));
}
if (org.users?.length) {
acc.push(
...org.users.map((user) => ({
id: user.userUnqId,
parent: org.ognzId,
text: user.userNm,
data: user,
droppable: false,
})),
);
}
if (org.users?.length) {
acc.push(
...org.users.map((user) => ({
id: user.userUnqId,
parent: org.ognzSn,
text: user.userNm,
data: user,
droppable: false,
})),
);
}
return acc;
}, []);
return acc;
},
[],
);
};
export const filterOrganizationTreeDataByKeyword = (
treeData: NodeModel<Organization | User>[],
export const filterUserOrganizationTreeDataByKeyword = (
treeData: NodeModel<TreeUserOrganization | TreeUser>[],
keyword: string,
): NodeModel<Organization | User>[] => {
): NodeModel<TreeUserOrganization | TreeUser>[] => {
return Array.from(
treeData.reduce((matchedNodes, node) => {
if (!node.text.includes(keyword)) return matchedNodes;
@@ -160,6 +166,6 @@ export const filterOrganizationTreeDataByKeyword = (
}
return matchedNodes;
}, new Set<NodeModel<Organization | User>>()),
}, new Set<NodeModel<TreeUserOrganization | TreeUser>>()),
);
};
@@ -1,5 +1,5 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import { Checkbox } from "@common/components";
import type { BoardPopup } from "@/entities/board";
export const columns: ColumnDef<BoardPopup>[] = [
@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import type { Contents } from "@/entities/contents";
import { Checkbox } from "@common/components";
import type { Contents } from "@/entities/content";
export const columns: ColumnDef<Contents>[] = [
{
@@ -19,10 +19,10 @@ export const columns: ColumnDef<Contents>[] = [
header: "승인자",
},
{
accessorKey: "regDt",
accessorKey: "aprvDt",
header: "승인일자",
cell: ({ row }) => {
const date = dayjs(row.original.regDt);
const date = dayjs(row.original.aprvDt);
return date.format("YYYY-MM-DD");
},
},
@@ -1,18 +0,0 @@
export interface User {
userUnqId: string;
userNm: string;
jbpsSeCd: string;
jbgdSeCd: string;
}
export interface Organization {
ognzId: string;
upOgnzId: string;
ognzNm: string;
users: User[];
children: Organization[];
}
export interface OrganizationService {
getOrganizationUserTreeList(): Promise<Organization[]>;
}
@@ -14,7 +14,7 @@ import {
DialogHeader,
DialogTitle,
Input,
} from "@common/components/ui";
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { BoardApi, type BoardPopup } from "@/entities/board";
import { columns } from "../model/board-table-columns";
@@ -14,10 +14,10 @@ import {
DialogHeader,
DialogTitle,
Input,
} from "@common/components/ui";
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { ContentsApi, type Contents } from "@/entities/contents";
import { columns } from "../model/content-table-colums";
import { ContentsApi, type Contents } from "@/entities/content";
import { columns } from "../model/contents-table-colums";
type ContentSearchDialogProps = {
handleContentSelect: (content: Contents) => void;
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ComponentProps } from "react";
import { Button, Input } from "@common/components/ui";
import { Button, Input } from "@common/components";
type EditableNodeProps = {
depth?: number;
+1 -2
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { NodeModel, TreeMethods } from "@minoru/react-dnd-treeview";
import { Button } from "@common/components/ui";
import { cn } from "@common/components/lib";
import { Button, cn } from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { MenuApi } from "../api/menu-service";
import type { TreeMenu } from "../model/menu-interface";
+4 -4
View File
@@ -13,7 +13,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components/ui";
} from "@common/components";
import { SiteApi } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
@@ -44,7 +44,7 @@ function MenuPage() {
enabled: !!selectedMenu?.menuCd,
});
const { data: treeData } = useQuery({
const { data: menuTreeData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
queryFn: () => MenuApi.getTreeMenuList(siteId),
select: (data) => {
@@ -187,8 +187,8 @@ function MenuPage() {
};
const tree = additionalNode
? [...(additionalNode ? [additionalNode] : []), ...(treeData ?? [])]
: (treeData ?? []);
? [...(additionalNode ? [additionalNode] : []), ...(menuTreeData ?? [])]
: (menuTreeData ?? []);
return (
<div className="card !mb-0 flex !min-h-[calc(100vh-16.8rem)] gap-2">
+52 -60
View File
@@ -1,17 +1,14 @@
"use client";
import { useRef } from "react";
import { DndProvider } from "react-dnd";
import type { UseFormReturn } from "react-hook-form";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Tree,
MultiBackend,
getBackendOptions,
type NodeModel,
type TreeMethods,
} from "@minoru/react-dnd-treeview";
import { Button } from "@common/components/ui";
import { Button } from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { MenuApi } from "../api/menu-service";
import type { MenuFormData, TreeMenu, TreeNode } from "../model/menu-interface";
@@ -61,66 +58,61 @@ function MenuTree({
return (
<>
<div className="max-h-[calc(100vh-33.5rem)] overflow-auto">
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={tree}
rootId={0}
ref={treeMethods}
render={(
node: NodeModel<TreeMenu>,
{ depth, isOpen, onToggle },
) => {
if (node.id === -1) {
return (
<EditableNode
value={additionalNodeForm.watch("menuKornNm") ?? ""}
onChange={(e) =>
additionalNodeForm.setValue("menuKornNm", e.target.value)
}
depth={depth}
onSave={onAdditionalNodeSave}
onCancel={onAdditionalNodeCancel}
/>
);
}
<Tree
tree={tree}
rootId={0}
ref={treeMethods}
render={(node: NodeModel<TreeMenu>, { depth, isOpen, onToggle }) => {
if (node.id === -1) {
return (
<MenuNode
node={node}
<EditableNode
value={additionalNodeForm.watch("menuKornNm") ?? ""}
onChange={(e) =>
additionalNodeForm.setValue("menuKornNm", e.target.value)
}
depth={depth}
treeMethods={treeMethods.current}
isOpen={isOpen}
isSelected={selectedMenu?.menuCd === node.id}
isAdditionalParent={isAdditionalParent(node)}
onToggle={onToggle}
onSelect={handleSelectNode(onSelectNode)}
onSave={onAdditionalNodeSave}
onCancel={onAdditionalNodeCancel}
/>
);
}}
onDrop={handleDropNode(updateTreeMenu)}
classes={{
root: "p-4",
container: "flex flex-col",
dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm",
placeholder: "relative",
}}
sort={false}
dropTargetOffset={5}
insertDroppableFirst={false}
canDrop={(_, { dragSource, dropTargetId }) => {
if (dragSource?.parent === dropTargetId) {
return true;
}
}}
placeholderRender={(_, { depth }) => {
return (
<div
className="absolute right-0 top-0 h-[0.2rem] bg-primary"
style={{ left: depth * 10 + 5 }}
/>
);
}}
/>
</DndProvider>
}
return (
<MenuNode
node={node}
depth={depth}
treeMethods={treeMethods.current}
isOpen={isOpen}
isSelected={selectedMenu?.menuCd === node.id}
isAdditionalParent={isAdditionalParent(node)}
onToggle={onToggle}
onSelect={handleSelectNode(onSelectNode)}
/>
);
}}
onDrop={handleDropNode(updateTreeMenu)}
classes={{
root: "p-4",
container: "flex flex-col",
dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm",
placeholder: "relative",
}}
sort={false}
dropTargetOffset={5}
insertDroppableFirst={false}
canDrop={(_, { dragSource, dropTargetId }) => {
if (dragSource?.parent === dropTargetId) {
return true;
}
}}
placeholderRender={(_, { depth }) => {
return (
<div
className="absolute right-0 top-0 h-[0.2rem] bg-primary"
style={{ left: depth * 10 + 5 }}
/>
);
}}
/>
</div>
<div className="flex gap-2">
<Button
@@ -17,20 +17,23 @@ import {
DialogTitle,
DialogTrigger,
Input,
} from "@common/components/ui";
import { cn } from "@common/components/lib";
import { ENDPOINT } from "@/shared/config";
import { OrganizationApi } from "../api/organization-service";
import type { Organization, User } from "../model/organization-interface";
cn,
} from "@common/components";
import {
filterOrganizationTreeDataByKeyword,
generateOrganizationTreeData,
UserOrganizationApi,
type TreeUserOrganization,
type TreeUser,
} from "@/entities/user-organization";
import { ENDPOINT } from "@/shared/config";
import {
filterUserOrganizationTreeDataByKeyword,
generateUserOrganizationTreeData,
} from "../lib/utils";
type TreeModel = Organization | User;
type TreeModel = TreeUserOrganization | TreeUser;
interface OrganizationSearchDialogProps {
handleUserSelect: (user: User) => void;
handleUserSelect: (user: TreeUser) => void;
}
function OrganizationSearchDialog({
@@ -44,13 +47,13 @@ function OrganizationSearchDialog({
const { data: treeData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.USER_ORGANIZATION],
queryFn: () => OrganizationApi.getOrganizationUserTreeList(),
select: (data) => generateOrganizationTreeData(data),
queryFn: () => UserOrganizationApi.getUserOrganizationTreeList(),
select: (data) => generateUserOrganizationTreeData(data),
});
const handleSelectNode = (node: NodeModel<TreeModel>) => {
setSelectedNode(node);
handleUserSelect(node.data as User);
handleUserSelect(node.data as TreeUser);
};
useEffect(() => {
@@ -79,7 +82,7 @@ function OrganizationSearchDialog({
<Button
size="sm"
onClick={() =>
setTree(filterOrganizationTreeDataByKeyword(tree, keyword))
setTree(filterUserOrganizationTreeDataByKeyword(tree, keyword))
}
>
<i className="diveicon di-search text-base" />
+4 -4
View File
@@ -13,10 +13,10 @@ import {
TableHead,
TableRow,
} from "@common/components";
import { FormItem, FormControl, FormField } from "@/shared/ui/form";
import type { BoardPopup } from "@/entities/board";
import type { Contents } from "@/entities/contents";
import type { User } from "../model/organization-interface";
import type { Contents } from "@/entities/content";
import type { TreeUser } from "@/entities/user-organization";
import { FormItem, FormControl, FormField } from "@/shared/ui/form";
import type { MenuFormData, MenuType } from "../model/menu-interface";
import { OrganizationSearchDialog } from "./organization-search-dialog";
import { ContentSearchDialog } from "./content-search-dialog";
@@ -37,7 +37,7 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
form.setValue("menuLnkgSnNm", content.contsTtl);
};
const handleUserSelect = (user: User) => {
const handleUserSelect = (user: TreeUser) => {
form.setValue("menuPicUserUnqId", user.userUnqId);
form.setValue("menuPicUserUnqIdNm", user.userNm);
};
@@ -1,7 +1,7 @@
import { type ColumnDef } from "@tanstack/react-table";
import type { RoleAuthorizations } from "./role-authorizations-interface";
import { Checkbox } from "@common/components/ui";
import type { ColumnDef } from "@tanstack/react-table";
import type { UseFormReturn } from "react-hook-form";
import { Checkbox } from "@common/components";
import type { RoleAuthorizations } from "./role-authorizations-interface";
import type { RoleAuthorizationsFormData } from "./role-authorizations-interface";
export const createColumns = (
@@ -24,7 +24,7 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@common/components/ui";
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import {
+1
View File
@@ -0,0 +1 @@
export { OrganizationPage } from "./ui/organization-page";
@@ -0,0 +1,39 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components";
import type { SearchUser } from "@/entities/user-organization";
export const columns: ColumnDef<SearchUser>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
accessorKey: "userNm",
header: "이름",
},
{
accessorKey: "ogdpSeNm",
header: "소속",
},
{
accessorKey: "regDt",
header: "등록일",
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD HH:mm:ss"),
},
];
@@ -0,0 +1,114 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type RowSelectionState,
} from "@tanstack/react-table";
import {
Button,
DataTable,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
Input,
DialogFooter,
DialogClose,
} from "@common/components";
import {
UserOrganizationApi,
type SearchUser,
} from "@/entities/user-organization";
import { ENDPOINT } from "@/shared/config";
import { columns } from "../model/additional-user-table-columns";
type AdditionalUserDialogProps = {
onAddUsers: (user: SearchUser[]) => void;
} & React.ComponentProps<typeof Dialog>;
function AdditionalUserDialog({
onAddUsers,
...props
}: AdditionalUserDialogProps) {
const [open, setOpen] = useState(false);
const [userNm, setUserNm] = useState("");
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { data: userList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.USER_ORGANIZATION],
queryFn: () => UserOrganizationApi.searchUser({ userNm }),
enabled: false,
});
const table = useReactTable({
data: userList?.content ?? [],
columns,
state: { rowSelection },
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
});
const handleAddUsers = () => {
const selectedRows = table.getSelectedRowModel().rows;
const userData = selectedRows.map((row) => row.original);
if (!userData) return;
onAddUsers(userData);
table.setRowSelection({});
};
const handleCancel = () => {
setRowSelection({});
setUserNm("");
};
useEffect(() => {
if (open) refetch();
}, [open]);
return (
<Dialog {...props} open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" color="green">
사용자 추가
</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>콘텐츠 조회</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="card card-border !m-0 flex w-full items-center gap-2">
<Input
placeholder="검색어를 입력하세요."
className="!text-[1.3rem]"
value={userNm}
onChange={(e) => setUserNm(e.target.value)}
/>
<Button onClick={() => refetch()}>
<i className="diveicon di-search" />
조회
</Button>
</div>
<div className="h-[40rem] w-full overflow-y-auto">
<DataTable table={table} />
</div>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button color="white" onClick={handleCancel}>
취소
</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={handleAddUsers}>추가</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { AdditionalUserDialog };
@@ -0,0 +1,84 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { NodeModel } from "@minoru/react-dnd-treeview";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@common/components";
import { DndTree } from "@/widgets/dnd-tree";
import {
generateOrganizationTreeData,
type OrganizationTree,
} from "@/entities/organization";
import { ENDPOINT } from "@/shared/config";
type MoveOrganizationDialogProps = {
buttonDisabled?: boolean;
handleMoveOrganization: (organization: OrganizationTree) => Promise<void>;
};
function MoveOrganizationDialog({
buttonDisabled,
handleMoveOrganization,
}: MoveOrganizationDialogProps) {
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const queryData = queryClient.getQueryData<OrganizationTree[]>([
ENDPOINT.CMS_SERVICE.ORGANIZATIONS,
]);
const organizationTreeData = generateOrganizationTreeData(queryData ?? []);
const [open, setOpen] = useState(false);
const [selectedOrganization, setSelectedOrganization] =
useState<OrganizationTree | null>(null);
const handleNodeSelect = (node: NodeModel<OrganizationTree>) => {
if (!node.data) return;
setSelectedOrganization(node.data);
};
const onMoveOrganization = async () => {
if (await confirm("이동하시겠습니까?")) {
await handleMoveOrganization(selectedOrganization as OrganizationTree);
setOpen(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" color="white" disabled={buttonDisabled}>
조직 이동
</Button>
</DialogTrigger>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>조직 이동</DialogTitle>
</DialogHeader>
<DndTree
tree={organizationTreeData ?? []}
selectedNodeId={selectedOrganization?.ognzSn ?? 0}
handleSelectNode={handleNodeSelect}
classes={{
root: "p-0",
}}
/>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button color="white">취소</Button>
</DialogClose>
<Button onClick={onMoveOrganization}>저장</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { MoveOrganizationDialog };
@@ -0,0 +1,207 @@
"use client";
import type { UseFormReturn } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
Input,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import type { OrganizationFormData } from "@/entities/organization";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
type OrganizationFormProps = {
form: UseFormReturn<OrganizationFormData>;
ognzSn?: number;
handleCreate: (data: OrganizationFormData) => Promise<void>;
handleUpdate: ({
ognzSn,
data,
}: {
ognzSn: number;
data: OrganizationFormData;
}) => Promise<void>;
handleDelete?: (ognzSn: number) => Promise<void>;
};
function OrganizationForm({
form,
ognzSn,
handleCreate,
handleUpdate,
handleDelete,
}: OrganizationFormProps) {
const isModifyMode = !!ognzSn;
const { confirm } = useConfirm();
const onSubmit = async (data: OrganizationFormData) => {
if (isModifyMode) {
await onUpdate(data);
} else {
await onCreate(data);
}
};
const onCreate = async (data: OrganizationFormData) => {
if (await confirm("저장하시겠습니까?")) {
handleCreate(data);
}
};
const onUpdate = async (data: OrganizationFormData) => {
if (await confirm("수정하시겠습니까?")) {
handleUpdate({ ognzSn: ognzSn!, data });
}
};
const onDelete = async () => {
if (await confirm("삭제하시겠습니까?")) {
handleDelete?.(ognzSn!);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{isModifyMode ? "조직도 수정" : "조직도 등록"}
</h3>
</div>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
<col width="15%" />
<col width="35%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>조직명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="ognzNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="조직명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableHead>정렬순서</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="sortSeq"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="number"
value={field.value ?? 0}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
className="!text-[1.3rem]"
placeholder="정렬순서를 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>조직설명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="ognzExpln"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="조직설명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableHead>사용여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button size="lg" type="submit">
저장
</Button>
{isModifyMode && (
<Button
size="lg"
color="red"
type="button"
onClick={onDelete}
disabled={!isModifyMode}
>
삭제
</Button>
)}
</div>
</form>
</Form>
);
}
export { OrganizationForm };
@@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import type { NodeModel } from "@minoru/react-dnd-treeview";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@common/components";
import { DndTree, EditableNode } from "@/widgets/dnd-tree";
import {
OrganizationApi,
organizationFormSchema,
generateOrganizationTreeData,
handleDropNode,
type OrganizationFormData,
type OrganizationTree,
type OrganizationTreeForDnd,
} from "@/entities/organization";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { OrganizationForm } from "./organization-form";
import { UserOrganizationSection } from "./user-organization-section";
function OrganizationPage() {
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const [additionalNode, setAdditionalNode] =
useState<NodeModel<OrganizationTree> | null>(null);
const [selectedOrganization, setSelectedOrganization] =
useState<OrganizationTree | null>(null);
const { data: organizationTreeData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.ORGANIZATIONS],
queryFn: () => OrganizationApi.getOrganizationsTreeList(),
select: (data) => generateOrganizationTreeData(data),
});
const { data: organizationInfo } = useQuery({
queryKey: [
ENDPOINT.CMS_SERVICE.ORGANIZATIONS,
selectedOrganization?.ognzSn,
],
queryFn: () =>
OrganizationApi.getOrganizationInfo(selectedOrganization!.ognzSn!),
enabled: !!selectedOrganization?.ognzSn,
});
const { mutateAsync: createOrganization } = useMutation({
mutationFn: (form: OrganizationFormData) =>
OrganizationApi.createOrganization(form),
onSuccess: () => {
alert("저장되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.ORGANIZATIONS],
});
},
});
const { mutateAsync: deleteOrganization } = useMutation({
mutationFn: (ognzSn: number) => OrganizationApi.deleteOrganization(ognzSn),
onSuccess: () => {
alert("삭제되었습니다.");
setSelectedOrganization(null);
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.ORGANIZATIONS],
});
},
});
const { mutateAsync: updateOrganizationName } = useMutation({
mutationFn: ({ ognzSn, name }: { ognzSn: number; name: string }) =>
OrganizationApi.updateOrganizationName(ognzSn, name),
onSuccess: () => {
alert("저장되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.ORGANIZATIONS],
});
},
});
const { mutateAsync: updateOrganization } = useMutation({
mutationFn: ({
ognzSn,
data,
}: {
ognzSn: number;
data: OrganizationFormData;
}) => OrganizationApi.updateOrganization(ognzSn, data),
onSuccess: () => {
alert("수정되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.ORGANIZATIONS],
});
},
});
const { mutateAsync: updateOrganizationByDnd } = useMutation({
mutationFn: (data: OrganizationTreeForDnd) =>
OrganizationApi.updateOrganizationByDnd([data]),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.ORGANIZATIONS],
});
},
});
const form = useForm<OrganizationFormData>({
resolver: zodResolver(organizationFormSchema),
defaultValues: {
ognzNm: "",
sortSeq: 1,
useYn: "Y",
},
values: {
ognzNm: organizationInfo?.ognzNm ?? "",
ognzExpln: organizationInfo?.ognzExpln ?? "",
sortSeq: organizationInfo?.sortSeq ?? 1,
useYn: organizationInfo?.useYn ?? "Y",
},
});
const additionalNodeForm = useForm<OrganizationFormData>({
resolver: zodResolver(organizationFormSchema),
defaultValues: {
ognzNm: "",
sortSeq: 1,
},
});
const tree = additionalNode
? [
...(additionalNode ? [additionalNode] : []),
...(organizationTreeData ?? []),
]
: (organizationTreeData ?? []);
const onSaveAdditionalNode = async () => {
await createOrganization({ ...additionalNodeForm.getValues() });
resetAdditionalNode();
};
const onDeleteOrganization = async () => {
if (await confirm("삭제하시겠습니까?")) {
deleteOrganization(selectedOrganization!.ognzSn!);
}
};
const resetAdditionalNode = () => {
additionalNodeForm.setValue("ognzNm", "");
setAdditionalNode(null);
};
const handleNodeSelect = (node: NodeModel<OrganizationTree>) => {
if (!node.data) return;
if (node.data.ognzSn === selectedOrganization?.ognzSn) {
setSelectedOrganization(null);
additionalNodeForm.setValue("upOgnzSn", null);
return;
}
setSelectedOrganization(node.data);
additionalNodeForm.setValue("upOgnzSn", (node.id as number) ?? 0);
};
const handleAddOrganization = () => {
if (additionalNode) return;
setAdditionalNode({
id: -1,
parent: selectedOrganization?.ognzSn ?? 0,
text: "",
});
};
return (
<div className="card !mb-0 flex !min-h-[calc(100vh-16.8rem)] gap-2">
<div className="card card-border w-[24rem]">
<div className="flex gap-2">
<Button
size="sm"
color="primary"
className="flex-1"
onClick={handleAddOrganization}
>
조직 추가
</Button>
<Button
size="sm"
color="red"
className="flex-1"
onClick={onDeleteOrganization}
disabled={!selectedOrganization}
>
조직 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<DndTree
tree={tree}
selectedNodeId={selectedOrganization?.ognzSn ?? 0}
renderEditableNode={(props) => (
<EditableNode
value={additionalNodeForm.watch("ognzNm")}
onChange={(e) =>
additionalNodeForm.setValue("ognzNm", e.target.value)
}
onSave={onSaveAdditionalNode}
onCancel={resetAdditionalNode}
{...props}
/>
)}
handleSelectNode={handleNodeSelect}
handleDropNode={handleDropNode(updateOrganizationByDnd)}
updateNodeName={async (id, name) => {
await updateOrganizationName({ ognzSn: id as number, name });
}}
classes={{ wrapper: "max-h-[calc(100vh-29rem)]" }}
/>
</div>
</div>
<div className="card card-border flex-1">
<OrganizationForm
form={form}
ognzSn={selectedOrganization?.ognzSn ?? 0}
handleCreate={createOrganization}
handleUpdate={updateOrganization}
handleDelete={deleteOrganization}
/>
{selectedOrganization?.ognzSn && (
<UserOrganizationSection ognzSn={selectedOrganization.ognzSn} />
)}
</div>
</div>
);
}
export { OrganizationPage };
@@ -0,0 +1,383 @@
import dayjs from "dayjs";
import React, { useEffect, useState, useCallback, memo } from "react";
import { useFieldArray, useForm, type UseFormReturn } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import {
useQuery,
useMutation,
useQueryClient,
keepPreviousData,
} from "@tanstack/react-query";
import {
Button,
Checkbox,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import {
UserOrganizationApi,
userOrganizationFormSchema,
type UserOrganizationFormData,
type UserOrganizationPayload,
type User,
type SearchUser,
type MoveUserOrganizationPayload,
} from "@/entities/user-organization";
import { CodeApi, type ICode } from "@/entities/code";
import type { OrganizationTree } from "@/entities/organization";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { AdditionalUserDialog } from "./additional-user-dialog";
import { MoveOrganizationDialog } from "./move-organization-dialog";
type UserOrganizationSectionProps = {
ognzSn: number;
};
function UserOrganizationSection({ ognzSn }: UserOrganizationSectionProps) {
const queryClient = useQueryClient();
const { confirm } = useConfirm();
const { setMessage: alert } = useAlertStore((state) => state);
const [userList, setUserList] = useState<User[]>([]);
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const isAllSelected =
userList.length > 0 && selectedUsers.length === userList.length;
const { data: userOrganizationList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.USER_ORGANIZATION, ognzSn],
queryFn: () => UserOrganizationApi.getUserOrganizationList({ ognzSn }),
enabled: !!ognzSn,
placeholderData: keepPreviousData,
});
const { data: jobPositionCodes } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "JPBSCLCD"],
queryFn: () => CodeApi.getCode("JPBSCLCD"),
});
const { data: jobGradeCodes } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "JDGDCLCD"],
queryFn: () => CodeApi.getCode("JDGDCLCD"),
});
const { mutate: saveUserOrganization } = useMutation({
mutationFn: (data: UserOrganizationPayload[]) =>
UserOrganizationApi.saveUserOrganization(data),
onSuccess: () => {
alert("저장되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.USER_ORGANIZATION, ognzSn],
});
},
});
const { mutate: moveUserOrganization } = useMutation({
mutationFn: (data: MoveUserOrganizationPayload[]) =>
UserOrganizationApi.moveUserOrganization(data),
onSuccess: () => {
alert("이동되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.USER_ORGANIZATION, ognzSn],
});
},
});
const form = useForm<UserOrganizationFormData>({
resolver: zodResolver(userOrganizationFormSchema),
values: {
userOrganization:
userOrganizationList?.content.map((user) => ({
mode: user.mode,
ognzSn: user.ognzSn,
userUnqId: user.userUnqId,
jbpsSeCd: user.jbpsSeCd,
jbgdSeCd: user.jbgdSeCd,
sortSeq: user.sortSeq,
})) ?? [],
},
});
const { append } = useFieldArray({
name: "userOrganization",
control: form.control,
});
const handleAddUsers = (users: SearchUser[]) => {
const generateUserData = (user: SearchUser) => ({
mode: "C",
ognzSn,
userUnqId: user.userUnqId,
jbpsSeCd: "",
jbgdSeCd: "",
sortSeq: 0,
});
const newFormData = users.map(generateUserData);
append(newFormData);
const newUserList = users.map((user) => ({
...generateUserData(user),
userUnqNm: user.userNm,
}));
const existingUserIds = new Set(newUserList.map((user) => user.userUnqId));
const filteredUserList = userList.filter(
(user) => !existingUserIds.has(user.userUnqId),
);
setUserList([...filteredUserList, ...newUserList]);
};
const handleToggleSelectUser = useCallback((user: User) => {
setSelectedUsers((prevSelected) => {
const isSelected = prevSelected.some(
(item) => item.userUnqId === user.userUnqId,
);
if (isSelected) {
return prevSelected.filter((item) => item.userUnqId !== user.userUnqId);
} else {
return [...prevSelected, user];
}
});
}, []);
const handleToggleSelectAll = () => {
if (isAllSelected) {
setSelectedUsers([]);
} else {
setSelectedUsers([...userList]);
}
};
const handleDeleteUsers = () => {
const selectedUserIds = selectedUsers.map((user) => user.userUnqId);
const updatedFormData = form.getValues("userOrganization").map((item) => {
if (selectedUserIds.includes(item.userUnqId)) {
return { ...item, mode: "D" };
}
return item;
});
form.setValue("userOrganization", updatedFormData);
setUserList((prevUsers) =>
prevUsers.filter((user) => !selectedUserIds.includes(user.userUnqId)),
);
setSelectedUsers([]);
};
const handleSaveUsers = async () => {
if (await confirm("저장하시겠습니까?")) {
const filteredData = form
.watch("userOrganization")
.filter((item) => item.mode !== "R");
saveUserOrganization(filteredData);
}
};
const handleMoveOrganization = async (organization: OrganizationTree) => {
const payloadData: MoveUserOrganizationPayload[] = selectedUsers.map(
(user) => ({
ognzSn: user.ognzSn,
toOgnzSn: organization.ognzSn!,
userUnqId: user.userUnqId,
}),
);
moveUserOrganization(payloadData);
};
const isUserSelected = useCallback(
(userUnqId: string) =>
selectedUsers.some((item) => item.userUnqId === userUnqId),
[selectedUsers],
);
useEffect(() => {
if (userOrganizationList?.content) {
setUserList(userOrganizationList.content);
setSelectedUsers([]);
}
}, [userOrganizationList]);
return (
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">소속원 목록</h3>
<div className="flex gap-2">
<MoveOrganizationDialog
buttonDisabled={
userOrganizationList?.content.length === 0 ||
selectedUsers.length === 0
}
handleMoveOrganization={handleMoveOrganization}
/>
<AdditionalUserDialog onAddUsers={handleAddUsers} />
<Button
size="sm"
color="red"
onClick={handleDeleteUsers}
disabled={selectedUsers.length === 0}
>
선택삭제
</Button>
<Button size="sm" onClick={handleSaveUsers}>
저장
</Button>
</div>
</div>
<div className="card max-h-[calc(100vh-58rem)] overflow-auto !p-0">
<Table>
<TableHeader className="sticky top-0">
<TableRow>
<TableHead>
<Checkbox
checked={isAllSelected}
onCheckedChange={handleToggleSelectAll}
/>
</TableHead>
<TableHead>NO</TableHead>
<TableHead>이름</TableHead>
<TableHead>직위</TableHead>
<TableHead>직급</TableHead>
<TableHead>정렬순서</TableHead>
<TableHead>최종수정자</TableHead>
<TableHead>최종수정일</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userList.map((user, index) => (
<TableRow key={user.userUnqId}>
<TableCell align="center">
<Checkbox
checked={isUserSelected(user.userUnqId)}
onCheckedChange={() => handleToggleSelectUser(user)}
/>
</TableCell>
<UserCells
form={form}
user={user}
index={index}
jobPositionCodeData={jobPositionCodes}
jobGradeCodeData={jobGradeCodes}
/>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
type UserCellsProps = {
form: UseFormReturn<UserOrganizationFormData>;
user: User;
index: number;
jobPositionCodeData?: ICode[];
jobGradeCodeData?: ICode[];
};
const UserCells = memo(
({
form,
user,
index,
jobPositionCodeData,
jobGradeCodeData,
}: UserCellsProps) => {
const updateMode = (index: number) => {
const mode = form.watch(`userOrganization.${index}.mode`);
if (mode === "R") {
form.setValue(`userOrganization.${index}.mode`, "U");
}
};
const updateFormValue =
(index: number, key: keyof UserOrganizationPayload) =>
(value: string | number) => {
updateMode(index);
form.setValue(`userOrganization.${index}.${key}`, value);
};
return (
<>
<TableCell align="center">{index + 1}</TableCell>
<TableCell align="center">{user.userUnqNm}</TableCell>
<TableCell align="center">
<CodeSelect
codeData={jobPositionCodeData}
defaultValue={user.jbpsSeCd}
onValueChange={updateFormValue(index, "jbpsSeCd")}
{...form.register(`userOrganization.${index}.jbpsSeCd`)}
/>
</TableCell>
<TableCell align="center">
<CodeSelect
codeData={jobGradeCodeData}
defaultValue={user.jbgdSeCd}
onValueChange={updateFormValue(index, "jbgdSeCd")}
{...form.register(`userOrganization.${index}.jbgdSeCd`)}
/>
</TableCell>
<TableCell align="center">
<Input
type="number"
min={0}
defaultValue={user.sortSeq}
onChange={(e) =>
updateFormValue(index, "sortSeq")(Number(e.target.value))
}
className="w-[6rem] !text-[1.3rem]"
/>
</TableCell>
<TableCell align="center">{user.mdfrNm}</TableCell>
<TableCell align="center">
{dayjs(user.mdfcnDt).format("YYYY-MM-DD")}
</TableCell>
</>
);
},
);
UserCells.displayName = "UserCells";
type CodeSelectProps = React.ComponentPropsWithoutRef<typeof Select> & {
codeData?: ICode[];
};
const CodeSelect = memo(({ codeData, ...props }: CodeSelectProps) => {
if (!codeData) return null;
const placeholderText =
codeData.find((item) => item.lwpoCd === props.defaultValue)?.lwpoCdNm ??
"선택";
return (
<Select {...props}>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder={placeholderText} />
</SelectTrigger>
<SelectContent>
{codeData.map((item) => (
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
{item.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
);
});
CodeSelect.displayName = "CodeSelect";
export { UserOrganizationSection };
@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { TemplateApi } from "../api/template-service";
@@ -15,6 +15,7 @@ import { TemplateForm } from "./template-form";
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createTemplate } = useMutation({
mutationFn: (data: TemplateFormData) => TemplateApi.createTemplate(data),
onSuccess: () => {
+2
View File
@@ -26,6 +26,8 @@ export const ENDPOINT = {
MENU_ROLES: "/cms-service/api/v1/menu-roles",
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
CONTENTS: "/cms-service/api/v1/contents",
CONTENTS_DETAILS: "/cms-service/api/v1/contents-details",
ORGANIZATIONS: "/cms-service/api/v1/ognzs",
USER_ORGANIZATION: "/cms-service/api/v1/user-ognz",
IMAGES: "/cms-service/api/v1/images",
BANNERS: "/cms-service/api/v1/bnrs",
+1 -1
View File
@@ -1,5 +1,5 @@
import { createStore } from "zustand/vanilla";
import type { MenuItem } from "@common/components/api";
import type { MenuItem } from "@/entities/menu";
export type MenuState = {
currentMenuInfo: MenuItem | null;
+1 -1
View File
@@ -4,7 +4,7 @@ import {
DialogFooter,
DialogClose,
Button,
} from "@common/components/ui";
} from "@common/components";
import { useAlertStore } from "../lib/use-alert-store";
function AlertDialog() {
+4 -2
View File
@@ -6,7 +6,7 @@ import {
DialogClose,
Button,
type ButtonProps,
} from "@common/components/ui";
} from "@common/components";
type ConfirmDialogProps = {
isOpen?: boolean;
@@ -24,7 +24,9 @@ function ConfirmDialog({
<Dialog open={isOpen ?? isGlobalOpen} onOpenChange={onCancel}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="text-center text-[1.4rem] text-foreground">{message}</p>
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
{message}
</p>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
+3 -1
View File
@@ -1,4 +1,6 @@
import { Skeleton } from "@common/components/ui/skeleton";
"use client";
import { Skeleton } from "@common/components";
function SkeletonPage() {
return (
+2
View File
@@ -0,0 +1,2 @@
export { DndTree } from "./ui/dnd-tree";
export { EditableNode } from "./ui/editable-node";
@@ -0,0 +1,105 @@
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 };
@@ -0,0 +1,138 @@
"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 };
@@ -0,0 +1,44 @@
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,7 +1,7 @@
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/ui";
import { Button } from "@common/components";
import { Form } from "@/shared/ui/form";
type FormWrapperProps<T extends FieldValues> = {
@@ -54,7 +54,7 @@ function FormWrapper<T extends FieldValues>({
<Button size="lg" type="submit">
저장
</Button>
{isModifyMode && (
{isModifyMode && handleDelete && (
<Button size="lg" color="red" type="button" onClick={onDelete}>
삭제
</Button>