[as] feat: 결재선관리 페이지 작업

This commit is contained in:
2026-05-18 19:08:38 +09:00
parent 51204e2ec1
commit 86566c7692
35 changed files with 472 additions and 211 deletions
@@ -40,7 +40,7 @@
│ │ ⋮⋮ | APLN-0001 | [ 1 ] | [검토자 ▼] | [김민수 ][선택] | … | [저장][삭제] │ │
│ │ ⋮⋮ | (신규) | [ 2 ] | [승인자 ▼] | [ ][선택] | — | [저장][취소] │ │
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
│ [행 추가] │
│ [행 추가] │
│ ※ 결재자 등록·수정은 그리드 셀에서 직접 입력. 검토인력만 [선택] 팝업/다이얼로그 │
│ ※ 행 추가·수정 중: 해당 행만 편집 가능, 나머지 행·[행 추가]·드래그 비활성 │
└───────────────────────────────────────────────────────────────────────────────────────────────┘
+1
View File
@@ -24,6 +24,7 @@
"axios": "^1.8.1",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"react-hook-form": "^7.54.2",
"zod": "^3.24.2"
+5 -1
View File
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
import type { MenuItem } from "@/entities/menu";
import { BaseLayout } from "@common/components";
@@ -18,11 +19,14 @@ export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) {
const pathname = usePathname();
const queryClient = useQueryClient();
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
const handleMenuClick = useCallback(
(item: MenuItem) => {
setCurrentMenuInfo(item);
router.push(item.urlPath);
},
[router],
[router, setCurrentMenuInfo],
);
return (
+6 -1
View File
@@ -5,7 +5,9 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@/shared/lib";
import { StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
interface ProvidersProps {
children: React.ReactNode;
@@ -15,7 +17,10 @@ export function Providers({ children }: ProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<AuthWrapper>{children}</AuthWrapper>
<AuthWrapper>
<MenuWrapper>{children}</MenuWrapper>
<AlertDialog />
</AuthWrapper>
</StoreProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
+1 -2
View File
@@ -1,6 +1,5 @@
export { codeService } from "./api/code-service";
export { useCodeList, useCodeListByUseYn, useCodeName } from "./api/queries";
export { findCodeLabel } from "./lib/find-code-label";
export { UP_CD } from "./lib/up-cd-constants";
export { CodeSelect } from "./ui/code-select";
export type { Code, CodeService } from "./model/code-interface";
export { CodeSelect } from "./ui/code-select";
@@ -1,5 +0,0 @@
/** 결재선 관리 SPEC 상위코드 */
export const UP_CD = {
DOC_SE: "ASDOCSECD",
ATRZ_SE: "ATRZSECD",
} as const;
@@ -0,0 +1 @@
export { MenuAuthorityApi } from "../api/menu-authority-service";
@@ -0,0 +1,19 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
MenuAuthorityService,
PageMenuAuthority,
} from "../model/menu-authority-interface";
export class MenuAuthorityApiService implements MenuAuthorityService {
/** MenuAuthrtApiController GET /api/v1/menu-authrts/pageinfo/{menuCd} */
async getPageMenuAuthority(menuCd: number): Promise<PageMenuAuthority[]> {
const response = await client.get<PageMenuAuthority[]>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/pageinfo/${menuCd}`,
);
return response.data;
}
}
export const MenuAuthorityApi = new MenuAuthorityApiService();
@@ -0,0 +1,2 @@
export { MenuAuthorityApi } from "./api/menu-authority-service";
export type { PageMenuAuthority } from "./model/menu-authority-interface";
@@ -0,0 +1,12 @@
/** MenuAuthrtApiController GET /pageinfo/{menuCd} 응답 DTO */
export interface PageMenuAuthority {
mngAuthrtYn: string;
inqAuthrtYn: string;
regAuthrtYn: string;
mdfcnAuthrtYn: string;
delAuthrtYn: string;
}
export interface MenuAuthorityService {
getPageMenuAuthority(menuCd: number): Promise<PageMenuAuthority[]>;
}
@@ -0,0 +1,18 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import { mapMenuTreeResponse } from "../lib/map-menu-tree-response";
import type { MenuTreeResponseDto } from "../model/menu-tree-response";
import type { MenuItem } from "../model/types";
export class MenuApiService {
/** MenuRoleApiController GET /api/v1/menu-roles/{siteId} */
async getMenuRoles(siteId: string): Promise<MenuItem[]> {
const response = await client.get<MenuTreeResponseDto[]>(
`${ENDPOINT.CMS_SERVICE.MENU_ROLES}/${siteId}`,
);
return response.data.map(mapMenuTreeResponse);
}
}
export const MenuApi = new MenuApiService();
+3
View File
@@ -1 +1,4 @@
export { MenuApi } from "./api/menu-service";
export { MenuWrapper } from "./lib/menu-wrapper";
export { getCurrentMenu } from "./lib/utils";
export { type MenuItem } from "./model/types";
@@ -0,0 +1,28 @@
import type { MenuTreeResponseDto } from "../model/menu-tree-response";
import type { MenuItem } from "../model/types";
function isRequiredUrlPath(menuLnkgTypeCd: string): boolean {
return menuLnkgTypeCd === "BOARD" || menuLnkgTypeCd === "CONTENTS";
}
export function mapMenuTreeResponse(dto: MenuTreeResponseDto): MenuItem {
return {
menuCd: dto.menuCd,
menuKornNm: dto.menuKornNm,
menuEngNm: dto.menuEngNm,
parentId: dto.parentId,
sortSeq: dto.sortSeq,
iconNm: dto.iconNm,
children: dto.children?.map(mapMenuTreeResponse) ?? null,
levelNo: dto.levelNo,
urlPath: dto.urlPath,
menuLnkgTypeCd: dto.menuLnkgTypeCd,
menuLnkgSn: dto.menuLnkgSn,
menuPath: dto.menuPath,
qrcd: dto.qrcd,
useYn: dto.useYn,
otptYn: dto.otptYn,
newWndwYn: dto.newWndwYn,
requiredUrlPath: isRequiredUrlPath(dto.menuLnkgTypeCd),
};
}
@@ -0,0 +1,62 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { SITE_ID } from "@/shared/config";
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
import { MenuApi } from "../api/menu-service";
import { getCurrentMenu } from "./utils";
function MenuWrapper({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const alert = useAlertStore((state) => state.setMessage);
const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } =
useMenuStore((state) => state);
const getCurrentMenuInfo = async () => {
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
if (!menuRoles) return;
return getCurrentMenu(menuRoles, pathname ?? "");
};
const getPageMenuAuthority = async () => {
const menuCd = getCookie("menu-code-key");
if (!menuCd) return;
const response = await MenuAuthorityApi.getPageMenuAuthority(
parseInt(menuCd, 10),
);
return response;
};
const checkInquiryAuthority = () => {
return pageMenuAuthority?.inqAuthrtYn === "Y";
};
useEffect(() => {
(async () => {
const currentMenu = await getCurrentMenuInfo();
if (currentMenu) setCurrentMenuInfo(currentMenu);
const response = await getPageMenuAuthority();
if (response?.length) setPageMenuAuthority(response[0]!);
})();
}, [pathname]);
useEffect(() => {
if (!pageMenuAuthority) return;
const inquiryAuthority = checkInquiryAuthority();
if (!inquiryAuthority) {
alert("페이지에 대한 조회 권한이 없습니다.", () => router.push("/"));
}
}, [pageMenuAuthority]);
return <>{children}</>;
}
export { MenuWrapper };
+21
View File
@@ -0,0 +1,21 @@
import type { MenuItem } from "../model/types";
export function getCurrentMenu(
menus: MenuItem[],
pathname: string,
): MenuItem | null {
for (const menu of menus) {
if (
menu.urlPath !== "" &&
(menu.urlPath === pathname || pathname.startsWith(menu.urlPath))
) {
return menu;
}
if (menu.children) {
const child = getCurrentMenu(menu.children, pathname);
if (child) return child;
}
}
return null;
}
@@ -0,0 +1,21 @@
/** MenuRoleApiController GET /api/v1/menu-roles/{siteId} 응답 DTO */
export interface MenuTreeResponseDto {
menuCd: number;
menuKornNm: string;
menuEngNm: string;
parentId: number | null;
sortSeq: number;
iconNm?: string;
children: MenuTreeResponseDto[] | null;
levelNo: number;
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
menuPath: string;
qrcd: string;
useYn: string;
otptYn: string;
newWndwYn: string;
menuHlpCn?: string;
menuSubKornNm?: string;
}
@@ -2,6 +2,10 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { generateClientId } from "@/shared/lib";
import { CodeSelect } from "@/entities/code";
import type { ElaprTmplt } from "@/entities/elapr";
import {
Button,
Dialog,
@@ -14,10 +18,6 @@ import {
Textarea,
} from "@common/components";
import { CodeSelect, UP_CD } from "@/entities/code";
import type { ElaprTmplt } from "@/entities/elapr";
import { generateClientId } from "@/shared/lib";
export interface ElaprTmpltFormValues {
elaprTmpltId: string;
elaprTmpltNm: string;
@@ -77,41 +77,40 @@ function ElaprTmpltFormDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-[52rem]">
<DialogHeader>
<DialogTitle>{mode === "create" ? "결재서식 등록" : "결재서식 수정"}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<Label htmlFor="elaprTmpltId">전자결재서식아이디</Label>
<Input
id="elaprTmpltId"
readOnly
{...register("elaprTmpltId", { required: true })}
/>
<Input id="elaprTmpltId" disabled {...register("elaprTmpltId", { required: true })} />
</div>
<div className="space-y-2">
<Label htmlFor="elaprTmpltNm">결재서식명</Label>
<Input
id="elaprTmpltNm"
{...register("elaprTmpltNm", { required: true })}
/>
<Input id="elaprTmpltNm" {...register("elaprTmpltNm", { required: true })} />
</div>
<div className="space-y-2">
<Label>문서구분</Label>
<CodeSelect
upCd={UP_CD.DOC_SE}
upCd="ASDOCSECD"
value={docSeComCd}
onValueChange={(v) => setValue("docSeComCd", v)}
placeholder="문서구분 선택"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="elaprTmpltExpln">서식 설명</Label>
<Textarea id="elaprTmpltExpln" rows={3} {...register("elaprTmpltExpln")} />
<Textarea
id="elaprTmpltExpln"
rows={3}
{...register("elaprTmpltExpln")}
className="!text-xl"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button type="submit" disabled={isSubmitting}>
@@ -5,23 +5,17 @@ import { useState } from "react";
import type { ElaprTmplt } from "@/entities/elapr";
import { ElaprAplnGrid } from "@/widgets/elapr-apln-grid";
import { ElaprTmpltList } from "@/widgets/elapr-tmplt-list";
import { PageTitle } from "@common/components";
const MENU_PATHS = ["디지털행정지원", "시스템관리", "결재선 관리"];
function ApprovalLineManagementPage() {
const [selectedTmplt, setSelectedTmplt] = useState<ElaprTmplt | null>(null);
return (
<div className="min-h-full bg-container pb-6">
<PageTitle title="결재선 관리" menuPaths={MENU_PATHS} />
<div className="space-y-6 px-[1.8rem]">
<ElaprTmpltList
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
onSelectTmplt={setSelectedTmplt}
/>
<ElaprAplnGrid elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null} />
</div>
<div className="space-y-6 px-[1.8rem] pb-6">
<ElaprTmpltList
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
onSelectTmplt={setSelectedTmplt}
/>
<ElaprAplnGrid elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null} />
</div>
);
}
+2
View File
@@ -13,6 +13,8 @@ export const ENDPOINT = {
},
CMS_SERVICE: {
CODE: "/cms-service/api/v1/code/cache",
MENU_ROLES: "/cms-service/api/v1/menu-roles",
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
},
};
+4 -2
View File
@@ -1,7 +1,9 @@
export { getCookie, removeCookie, setCookie } from "./cookie";
export { dayjs } from "./dayjs";
export { queryClient } from "./query-client";
export { useUserStore } from "./use-user-store";
export { generateClientId } from "./generate-client-id";
export { matchQueryStatus } from "./match-query-status";
export { queryClient } from "./query-client";
export { useAlertStore } from "./use-alert-store";
export { useDataTable } from "./use-data-table";
export { useMenuStore } from "./use-menu-store";
export { useUserStore } from "./use-user-store";
+16
View File
@@ -0,0 +1,16 @@
import { useContext } from "react";
import { useStore } from "zustand";
import { StoreContext } from "../providers/store-provider";
import type { AlertStore } from "../store/alert-store";
export const useAlertStore = <T>(selector: (store: AlertStore) => T): T => {
const storeContext = useContext(StoreContext);
if (!storeContext) {
throw new Error("useAlertStore must be used within StoreProvider");
}
return useStore(storeContext.alertStore, selector);
};
+16
View File
@@ -0,0 +1,16 @@
import { useContext } from "react";
import { useStore } from "zustand";
import { StoreContext } from "../providers/store-provider";
import type { MenuStore } from "../store/menu-store";
export const useMenuStore = <T>(selector: (store: MenuStore) => T): T => {
const storeContext = useContext(StoreContext);
if (!storeContext) {
throw new Error("useMenuStore must be used within StoreProvider");
}
return useStore(storeContext.menuStore, selector);
};
@@ -2,9 +2,11 @@
import { createContext, useRef } from "react";
import { createUserStore } from "../store";
import { createAlertStore, createMenuStore, createUserStore } from "../store";
type StoreContextType = {
alertStore: ReturnType<typeof createAlertStore>;
menuStore: ReturnType<typeof createMenuStore>;
userStore: ReturnType<typeof createUserStore>;
};
@@ -14,6 +16,8 @@ export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
const storeRef = useRef<StoreContextType | null>(null);
if (storeRef.current === null) {
storeRef.current = {
alertStore: createAlertStore(),
menuStore: createMenuStore(),
userStore: createUserStore(),
};
}
+25
View File
@@ -0,0 +1,25 @@
import { createStore } from "zustand/vanilla";
export type AlertState = {
message: string;
callback?: () => void;
};
export type AlertActions = {
setMessage: (message: string, callback?: () => void) => void;
};
export type AlertStore = AlertState & AlertActions;
export const defaultInitState: AlertState = {
message: "",
callback: undefined,
};
export const createAlertStore = (initState: AlertState = defaultInitState) => {
return createStore<AlertStore>()((set) => ({
...initState,
setMessage: (message: string, callback?: () => void) =>
set(() => ({ message, callback })),
}));
};
+2
View File
@@ -1 +1,3 @@
export { type AlertStore,createAlertStore } from "./alert-store";
export { createMenuStore, type MenuStore } from "./menu-store";
export { createUserStore, type IUserInfo, type UserStore } from "./user-store";
+37
View File
@@ -0,0 +1,37 @@
import { createStore } from "zustand/vanilla";
import type { MenuItem } from "@/entities/menu";
interface PageMenuAuthority {
mngAuthrtYn: string;
inqAuthrtYn: string;
regAuthrtYn: string;
mdfcnAuthrtYn: string;
delAuthrtYn: string;
}
export type MenuState = {
currentMenuInfo: MenuItem | null;
pageMenuAuthority: PageMenuAuthority | null;
};
export type MenuActions = {
setCurrentMenuInfo: (menu: MenuItem) => void;
setPageMenuAuthority: (authority: PageMenuAuthority) => void;
};
export type MenuStore = MenuState & MenuActions;
export const defaultInitState: MenuState = {
currentMenuInfo: null,
pageMenuAuthority: null,
};
export const createMenuStore = (initState: MenuState = defaultInitState) => {
return createStore<MenuStore>()((set) => ({
...initState,
setCurrentMenuInfo: (menu: MenuItem) => set({ currentMenuInfo: menu }),
setPageMenuAuthority: (authority: PageMenuAuthority) =>
set({ pageMenuAuthority: authority }),
}));
};
+37
View File
@@ -0,0 +1,37 @@
"use client";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
} from "@common/components";
import { useAlertStore } from "../lib/use-alert-store";
function AlertDialog() {
const { message, callback, setMessage } = useAlertStore((state) => state);
const handleOpenChange = () => {
setMessage("");
callback?.();
};
return (
<Dialog open={!!message} onOpenChange={handleOpenChange}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="text-foreground whitespace-pre-line text-center text-[1.4rem]">
{message}
</p>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button>확인</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { AlertDialog };
+10 -22
View File
@@ -1,32 +1,20 @@
"use client";
import { PageTitle } from "@common/components";
const mockCurrentMenuInfo = {
menuCd: 0,
menuKornNm: "메인",
menuEngNm: "Main",
parentId: null,
sortSeq: 0,
children: null,
levelNo: 0,
urlPath: "/",
menuLnkgTypeCd: "",
menuLnkgSn: 0,
menuPath: "메인",
qrcd: "",
useYn: "Y",
otptYn: "Y",
newWndwYn: "N",
requiredUrlPath: false,
};
import { useMenuStore } from "../lib/use-menu-store";
function PageLayout({ children }: { children: React.ReactNode }) {
const menuPaths = mockCurrentMenuInfo.menuPath.split(">").map((path) => path.trim());
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
const menuPaths = currentMenuInfo?.menuPath
?.split(">")
.map((path) => path.trim());
return (
<div className="bg-container h-auto min-h-full pb-2">
<PageTitle title={mockCurrentMenuInfo.menuKornNm} menuPaths={menuPaths} />
<PageTitle
title={currentMenuInfo?.menuKornNm ?? ""}
menuPaths={menuPaths ?? []}
/>
{children}
</div>
);
@@ -1,10 +1,11 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { arrayMove } from "@dnd-kit/sortable";
import type { ElaprApln } from "@/entities/elapr";
import { generateClientId } from "@/shared/lib";
import type { ElaprApln } from "@/entities/elapr";
import type { ElaprAplnRow } from "../model/types";
@@ -60,11 +61,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
if (isEditingLocked) return;
setEditingRowId(aplnId);
setRows((prev) =>
prev.map((row) =>
row.aplnId === aplnId
? { ...row, snapshot: { ...row } }
: row,
),
prev.map((row) => (row.aplnId === aplnId ? { ...row, snapshot: { ...row } } : row)),
);
},
[isEditingLocked],
@@ -74,11 +71,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
(aplnId: string, patch: Partial<ElaprAplnRow>) => {
if (editingRowId !== aplnId) return;
setRows((prev) =>
prev.map((row) =>
row.aplnId === aplnId
? { ...row, ...patch, isDirty: true }
: row,
),
prev.map((row) => (row.aplnId === aplnId ? { ...row, ...patch, isDirty: true } : row)),
);
},
[editingRowId],
@@ -114,8 +107,11 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
}, []);
const reorderRows = useCallback(
(activeId: string, overId: string) => {
if (isEditingLocked) return;
(activeId: string, overId: string): ElaprAplnRow[] => {
if (isEditingLocked) return [];
let newRows: ElaprAplnRow[] = [];
setRows((prev) => {
const oldIndex = prev.findIndex((r) => r.aplnId === activeId);
const newIndex = prev.findIndex((r) => r.aplnId === overId);
@@ -126,8 +122,11 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
atrzSeqNo: idx + 1,
isDirty: true,
}));
newRows = moved;
return moved;
});
return newRows;
},
[isEditingLocked],
);
@@ -1,16 +1,11 @@
"use client";
import { useState } from "react";
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import {
Button,
Table,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { useAlertStore } from "@/shared/lib";
import {
useCreateElaprApln,
useDeleteElaprApln,
@@ -18,11 +13,21 @@ import {
useUpdateElaprApln,
} from "@/entities/elapr";
import { AprvrPickerDialog, type ReviewerOption } from "@/features/aprvr-picker";
import {
Button,
LoadingWithAnimation,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { useAplnRows } from "../lib/use-apln-rows";
import { useDndSensors } from "../lib/use-dnd-sensors";
import { validateAplnRow, validateUniqueSeqNo } from "../lib/validators";
import type { ElaprAplnRow } from "../model/types";
import { SortableAplnRow } from "./sortable-apln-row";
interface ElaprAplnGridProps {
@@ -32,7 +37,8 @@ interface ElaprAplnGridProps {
function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [pickingRowId, setPickingRowId] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const alert = useAlertStore((state) => state.setMessage);
const aplnQuery = useElaprAplnPage({
elaprTmpltId: elaprTmpltId ?? undefined,
@@ -67,20 +73,18 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
reorderRows(String(active.id), String(over.id));
const payloadRows = reorderRows(String(active.id), String(over.id));
};
const handleRowSave = async (row: ElaprAplnRow) => {
const fieldError = validateAplnRow(row);
if (fieldError) {
setStatusMessage(fieldError);
alert(fieldError);
return;
}
const dupError = validateUniqueSeqNo(
rows.map((r) => (r.aplnId === row.aplnId ? row : r)),
);
const dupError = validateUniqueSeqNo(rows.map((r) => (r.aplnId === row.aplnId ? row : r)));
if (dupError) {
setStatusMessage(dupError);
alert(dupError);
return;
}
@@ -107,7 +111,6 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
}
clearEditing();
setStatusMessage(null);
await aplnQuery.refetch();
};
@@ -130,7 +133,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
const dupError = validateUniqueSeqNo(rows);
if (dupError) {
setStatusMessage(dupError);
alert(dupError);
return;
}
@@ -139,7 +142,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
for (const row of dirtyRows) {
const fieldError = validateAplnRow(row);
if (fieldError) {
setStatusMessage(fieldError);
alert(fieldError);
return;
}
@@ -164,7 +167,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
}
}
setStatusMessage("결재선이 저장되었습니다.");
alert("결재선이 저장되었습니다.");
await aplnQuery.refetch();
};
@@ -178,54 +181,41 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
};
return (
<section
className={`rounded-lg border border-border bg-background p-4 ${isDisabled ? "pointer-events-none opacity-50" : ""}`}
>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h3 className="text-lg font-semibold">결재선 관리</h3>
<section className="card card-border mx-0 pt-0">
<div className="card-header flex flex-wrap items-center justify-between gap-3">
<h3 className="text-2xl font-semibold">결재선 관리</h3>
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-muted-foreground">
{elaprTmpltId
? `선택된 서식: ${elaprTmpltId}`
: "서식을 선택하면 결재선을 관리할 수 있습니다."}
</span>
<Button
type="button"
variant="outline"
color="white"
disabled={isDisabled || isEditingLocked}
onClick={addRow}
>
행 추가
+ 행 추가
</Button>
<Button
type="button"
disabled={isDisabled || isEditingLocked}
onClick={handleBulkSave}
>
<Button type="button" disabled={isDisabled || isEditingLocked} onClick={handleBulkSave}>
결재선 저장
</Button>
</div>
</div>
{statusMessage && (
<p className="mb-3 text-sm text-primary" role="status">
{statusMessage}
</p>
)}
{aplnQuery.isPending && elaprTmpltId ? (
<p className="py-8 text-center text-sm text-muted-foreground">로딩 중…</p>
<div className="flex h-full w-full items-center justify-center py-8">
<div className="relative size-[4rem]">
<LoadingWithAnimation />
</div>
</div>
) : !elaprTmpltId ? (
<p className="py-8 text-center text-sm text-muted-foreground">
<p className="py-8 text-center text-lg text-muted-foreground">
상단 목록에서 결재서식을 선택해 주세요.
</p>
) : rows.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
<p className="py-8 text-center text-lg text-muted-foreground">
등록된 결재선이 없습니다. [행 추가]로 결재자를 등록하세요.
</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table variant="line" className="w-full">
<Table className="w-full">
<TableHeader>
<TableRow>
<TableHead className="w-10" />
@@ -237,7 +227,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<tbody>
<TableBody>
<SortableContext
items={rows.map((r) => r.aplnId)}
strategy={verticalListSortingStrategy}
@@ -261,7 +251,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
/>
))}
</SortableContext>
</tbody>
</TableBody>
</Table>
</DndContext>
)}
@@ -2,14 +2,10 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Button,
Input,
TableCell,
TableRow,
} from "@common/components";
import { Equal } from "lucide-react";
import { CodeSelect, findCodeLabel, UP_CD, useCodeList } from "@/entities/code";
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
import { Button, Input, TableCell, TableRow } from "@common/components";
import type { ElaprAplnRow } from "../model/types";
@@ -38,16 +34,12 @@ function SortableAplnRow({
onChange,
onPickAprvr,
}: SortableAplnRowProps) {
const { data: atrzSeCodes } = useCodeList(UP_CD.ATRZ_SE);
const { data: atrzSeCodes } = useCodeList("ATRZSECD");
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: row.aplnId, disabled: dragDisabled });
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: row.aplnId,
disabled: dragDisabled,
});
const style = {
transform: CSS.Transform.toString(transform),
@@ -56,26 +48,24 @@ function SortableAplnRow({
};
const rowDisabled = isLocked && !isEditing;
const atrzSeLabel =
row.atrzSeComNm ?? findCodeLabel(atrzSeCodes, row.atrzSeComCd);
const atrzSeLabel = row.atrzSeComNm ?? findCodeLabel(atrzSeCodes, row.atrzSeComCd);
return (
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
<TableCell align="center" className="w-10">
<button
<Button
type="button"
className="cursor-grab disabled:cursor-not-allowed disabled:opacity-40"
size="icon"
className="cursor-grab !bg-transparent text-muted-foreground disabled:cursor-not-allowed disabled:opacity-40"
disabled={dragDisabled}
{...attributes}
{...listeners}
aria-label="순서 변경"
>
<span className="select-none text-muted-foreground" aria-hidden>
⋮⋮
</span>
</button>
<Equal className="!size-6" />
</Button>
</TableCell>
<TableCell align="left">{row.aplnId}</TableCell>
<TableCell align="center">{row.aplnId}</TableCell>
<TableCell align="center">
{isEditing ? (
<Input
@@ -88,13 +78,13 @@ function SortableAplnRow({
}
/>
) : (
(row.atrzSeqNo ?? "—")
(row.atrzSeqNo ?? "-")
)}
</TableCell>
<TableCell align="center">
{isEditing ? (
<CodeSelect
upCd={UP_CD.ATRZ_SE}
upCd="ATRZSECD"
className="min-w-[120px]"
value={row.atrzSeComCd ?? ""}
onValueChange={(v) =>
@@ -106,22 +96,20 @@ function SortableAplnRow({
placeholder="결재구분"
/>
) : (
atrzSeLabel || "—"
atrzSeLabel || "-"
)}
</TableCell>
<TableCell align="left">
<div className="flex items-center gap-2">
<span className="min-w-[80px] text-sm">
{row.aprvrNm || row.aprvrUuid || "—"}
</span>
<TableCell align="center">
<div className="flex items-center justify-center gap-4">
<span>{row.aprvrNm || row.aprvrUuid || "-"}</span>
{isEditing && (
<Button type="button" size="sm" variant="outline" onClick={onPickAprvr}>
<Button type="button" size="sm" onClick={onPickAprvr}>
선택
</Button>
)}
</div>
</TableCell>
<TableCell align="center">{row.isNew ? "—" : (row.regDt ?? "—")}</TableCell>
<TableCell align="center">{row.isNew ? "-" : (row.regDt ?? "-")}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
{isEditing ? (
@@ -129,29 +117,16 @@ function SortableAplnRow({
<Button type="button" size="sm" onClick={onSave}>
저장
</Button>
<Button type="button" size="sm" variant="outline" onClick={onCancel}>
<Button type="button" size="sm" color="white" onClick={onCancel}>
취소
</Button>
</>
) : (
<>
<Button
type="button"
size="sm"
variant="outline"
disabled={rowDisabled}
onClick={onStartEdit}
>
<Button type="button" size="sm" disabled={rowDisabled} onClick={onStartEdit}>
수정
</Button>
<Button
type="button"
size="sm"
variant="outline"
color="red"
disabled={rowDisabled}
onClick={onDelete}
>
<Button type="button" size="sm" color="red" disabled={rowDisabled} onClick={onDelete}>
삭제
</Button>
</>
@@ -1,9 +1,9 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import { Button } from "@common/components";
import type { ElaprTmplt } from "@/entities/elapr";
import { Button } from "@common/components";
const columnHelper = createColumnHelper<ElaprTmplt>();
@@ -34,20 +34,11 @@ export function createTmpltColumns({ onEdit, onDelete }: CreateTmpltColumnsOptio
id: "actions",
header: "관리",
cell: ({ row }) => (
<div
className="flex justify-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Button type="button" size="sm" variant="outline" onClick={() => onEdit(row.original)}>
<div className="flex justify-center gap-2" onClick={(e) => e.stopPropagation()}>
<Button type="button" size="sm" onClick={() => onEdit(row.original)}>
수정
</Button>
<Button
type="button"
size="sm"
variant="outline"
color="red"
onClick={() => onDelete(row.original)}
>
<Button type="button" size="sm" color="red" onClick={() => onDelete(row.original)}>
삭제
</Button>
</div>
@@ -1,21 +1,19 @@
"use client";
import { useMemo, useState } from "react";
import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
import { Button } from "@common/components";
import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import {
type ElaprTmplt,
useCreateElaprTmplt,
useDeleteElaprTmplt,
useElaprTmpltPage,
useUpdateElaprTmplt,
type ElaprTmplt,
} from "@/entities/elapr";
import {
ElaprTmpltFormDialog,
type ElaprTmpltFormValues,
} from "@/features/elapr-tmplt-dialog";
import { useDataTable } from "@/shared/lib";
import { ElaprTmpltFormDialog, type ElaprTmpltFormValues } from "@/features/elapr-tmplt-dialog";
import { Button } from "@common/components";
import { createTmpltColumns } from "../model/columns";
@@ -66,23 +64,19 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps)
enableMultiRowSelection: false,
getRowId: (row) => row.elaprTmpltId,
onRowSelectionChange: (updater) => {
const next =
typeof updater === "function" ? updater(rowSelection) : updater;
const next = typeof updater === "function" ? updater(rowSelection) : updater;
setRowSelection(next);
const selectedId = Object.keys(next)[0];
if (!selectedId) {
onSelectTmplt(null);
return;
}
const selected = tmpltQuery.data?.content.find(
(t) => t.elaprTmpltId === selectedId,
);
const selected = tmpltQuery.data?.content.find((t) => t.elaprTmpltId === selectedId);
onSelectTmplt(selected ?? null);
},
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
onRowClick: (row) => {
setRowSelection({ [row.elaprTmpltId]: true });
onSelectTmplt(row);
@@ -113,9 +107,9 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps)
};
return (
<section className="rounded-lg border border-border bg-background p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">전자결재선 관리 목록</h3>
<section className="card card-border mx-0 bg-background pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">전자결재선 관리 목록</h3>
<Button type="button" onClick={() => setDialogMode("create")}>
서식 등록
</Button>
+2 -6
View File
@@ -1,15 +1,11 @@
import type { Config } from "tailwindcss";
import presetConfig from "../../tailwind.config.preset";
const config: Config = {
presets: [presetConfig],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./shared/**/*.{js,ts,jsx,tsx,mdx}",
"./entities/**/*.{js,ts,jsx,tsx,mdx}",
"./features/**/*.{js,ts,jsx,tsx,mdx}",
"./widgets/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"../../packages/common/components/dist/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
+3
View File
@@ -171,6 +171,9 @@ importers:
js-cookie:
specifier: ^3.0.5
version: 3.0.5
lucide-react:
specifier: ^0.474.0
version: 0.474.0(react@19.2.4)
next:
specifier: 15.3.8
version: 15.3.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)