feat: 조직 이동 기능 작업

This commit is contained in:
2025-04-24 13:59:26 +09:00
parent f6bcf8ebfe
commit abb428a3c9
18 changed files with 184 additions and 43 deletions
@@ -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>;
}
@@ -4,7 +4,7 @@ import { Checkbox } from "@common/components/ui";
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");
}
@@ -19,7 +19,7 @@ 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("저장되었습니다.");
@@ -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[]>;
}
@@ -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("저장되었습니다.");
},
@@ -5,10 +5,11 @@ import type {
UserOrganizationParams,
UserOrganizationList,
UserOrganizationService,
UserOrganizationPayloadData,
UserOrganizationPayload,
SearchUserParams,
SearchUserList,
User,
MoveUserOrganizationPayload,
} from "../model/user-organization-interface";
export class UserOrganizationApiService implements UserOrganizationService {
@@ -38,7 +39,7 @@ export class UserOrganizationApiService implements UserOrganizationService {
}
public async saveUserOrganization(
data: UserOrganizationPayloadData[],
data: UserOrganizationPayload[],
): Promise<User[]> {
const response = await client.post<User[]>(
`${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}`,
@@ -46,6 +47,16 @@ export class UserOrganizationApiService implements UserOrganizationService {
);
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();
@@ -4,7 +4,8 @@ export {
type TreeUserOrganization,
type TreeUser,
type UserOrganizationFormData,
type UserOrganizationPayloadData,
type UserOrganizationPayload,
type User,
type SearchUser,
type MoveUserOrganizationPayload,
} from "./model/user-organization-interface";
@@ -53,6 +53,12 @@ export interface TreeUserOrganization {
children: TreeUserOrganization[];
}
export interface MoveUserOrganizationPayload {
ognzSn: number;
toOgnzSn: number;
userUnqId: string;
}
export const userOrganizationPayloadSchema = z.object({
mode: z.string(),
ognzSn: z.number(),
@@ -68,7 +74,7 @@ export const userOrganizationFormSchema = z.object({
export type UserOrganizationList = PageableList<User>;
export type SearchUserList = PageableList<SearchUser>;
export type UserOrganizationPayloadData = z.infer<
export type UserOrganizationPayload = z.infer<
typeof userOrganizationPayloadSchema
>;
export type UserOrganizationFormData = z.infer<
@@ -81,5 +87,6 @@ export interface UserOrganizationService {
): Promise<UserOrganizationList>;
getUserOrganizationTreeList(): Promise<TreeUserOrganization[]>;
searchUser(params: SearchUserParams): Promise<SearchUserList>;
saveUserOrganization(data: UserOrganizationPayloadData[]): Promise<User[]>;
saveUserOrganization(data: UserOrganizationPayload[]): Promise<User[]>;
moveUserOrganization(data: MoveUserOrganizationPayload[]): Promise<User[]>;
}
+2 -2
View File
@@ -12,7 +12,7 @@ import {
} 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 { 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],
+2 -2
View File
@@ -12,7 +12,7 @@ import {
} 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 { 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],
@@ -71,7 +71,7 @@ function AdditionalUserDialog({
return (
<Dialog {...props} open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" color="gray">
<Button size="sm" color="green">
사용자 추가
</Button>
</DialogTrigger>
@@ -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/ui";
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 };
@@ -30,14 +30,17 @@ import {
UserOrganizationApi,
userOrganizationFormSchema,
type UserOrganizationFormData,
type UserOrganizationPayloadData,
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;
@@ -72,7 +75,7 @@ function UserOrganizationSection({ ognzSn }: UserOrganizationSectionProps) {
});
const { mutate: saveUserOrganization } = useMutation({
mutationFn: (data: UserOrganizationPayloadData[]) =>
mutationFn: (data: UserOrganizationPayload[]) =>
UserOrganizationApi.saveUserOrganization(data),
onSuccess: () => {
alert("저장되었습니다.");
@@ -82,6 +85,17 @@ function UserOrganizationSection({ ognzSn }: UserOrganizationSectionProps) {
},
});
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: {
@@ -172,6 +186,17 @@ function UserOrganizationSection({ ognzSn }: UserOrganizationSectionProps) {
}
};
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),
@@ -181,6 +206,7 @@ function UserOrganizationSection({ ognzSn }: UserOrganizationSectionProps) {
useEffect(() => {
if (userOrganizationList?.content) {
setUserList(userOrganizationList.content);
setSelectedUsers([]);
}
}, [userOrganizationList]);
@@ -189,6 +215,13 @@ function UserOrganizationSection({ ognzSn }: UserOrganizationSectionProps) {
<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"
@@ -15,6 +15,7 @@ type CustomNodeProps<T extends TreeData> = {
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>;
@@ -27,6 +28,7 @@ function CustomNode<T extends TreeData>({
isSelected,
isAdditionalParent,
treeMethods,
readOnly,
onToggle,
onSelect,
updateNodeName,
@@ -58,8 +60,9 @@ function CustomNode<T extends TreeData>({
<div
style={{ paddingInlineStart: indent }}
className={cn(
"group flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent",
"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 ? (
+17 -11
View File
@@ -9,6 +9,7 @@ import {
type NodeModel,
type TreeMethods,
type DropOptions,
type Classes,
} from "@minoru/react-dnd-treeview";
import { Button } from "@common/components/ui";
import { cn } from "@common/components/lib";
@@ -22,14 +23,14 @@ type EditableNodeProps = { depth: number };
type DndTreeProps<T extends TreeData> = {
tree: NodeModel<T>[];
selectedNodeId: number;
selectedNodeId?: number;
classes?: {
wrapper: string;
};
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>;
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>({
@@ -63,7 +64,8 @@ function DndTree<T extends TreeData>({
rootId={0}
ref={treeMethods}
render={(node: NodeModel<T>, { depth, isOpen, onToggle }) => {
if (node.id === -1) return renderEditableNode({ depth });
if (node.id === -1)
return renderEditableNode?.({ depth }) ?? <></>;
return (
<CustomNode
node={node}
@@ -72,18 +74,22 @@ function DndTree<T extends TreeData>({
isOpen={isOpen}
isSelected={selectedNodeId === node.id}
isAdditionalParent={isAdditionalParent(node)}
readOnly={typeof updateNodeName === "undefined"}
onToggle={onToggle}
onSelect={handleSelectNode}
updateNodeName={updateNodeName}
onSelect={(node) => handleSelectNode?.(node)}
updateNodeName={(id, nodeName) =>
updateNodeName?.(id, nodeName) ?? Promise.resolve()
}
/>
);
}}
onDrop={handleDropNode}
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}