[as] feat: 결재선 관리 페이지 구현

This commit is contained in:
2026-05-18 17:40:21 +09:00
parent 0e067bb5fc
commit 25affc06b9
43 changed files with 2071 additions and 236 deletions
@@ -0,0 +1,29 @@
import { client } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config";
import type { Code, CodeService } from "../model/code-interface";
async function getList(upCd: string): Promise<Code[]> {
const response = await client.get<Code[]>(`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`);
return response.data;
}
async function getListByUseYn(upCd: string, useYn: string): Promise<Code[]> {
const response = await client.get<Code[]>(
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}/${useYn}`,
);
return response.data;
}
async function getCodeName(upCd: string, lwpoCd: string): Promise<string> {
const response = await client.get<string>(
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}/lwpoCd/${lwpoCd}`,
);
return response.data;
}
export const codeService = {
getList,
getListByUseYn,
getCodeName,
} satisfies CodeService;
+36
View File
@@ -0,0 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { codeService } from "./code-service";
const codeBaseKey = [ENDPOINT.CMS_SERVICE.CODE] as const;
const CODE_STALE_TIME = 1000 * 60 * 10;
export function useCodeList(upCd: string | undefined) {
return useQuery({
queryKey: [...codeBaseKey, upCd],
queryFn: () => codeService.getList(upCd!),
enabled: Boolean(upCd),
staleTime: CODE_STALE_TIME,
});
}
export function useCodeListByUseYn(upCd: string | undefined, useYn: string) {
return useQuery({
queryKey: [...codeBaseKey, upCd, useYn],
queryFn: () => codeService.getListByUseYn(upCd!, useYn),
enabled: Boolean(upCd && useYn),
staleTime: CODE_STALE_TIME,
});
}
export function useCodeName(upCd: string | undefined, lwpoCd: string | undefined) {
return useQuery({
queryKey: [...codeBaseKey, upCd, "name", lwpoCd],
queryFn: () => codeService.getCodeName(upCd!, lwpoCd!),
enabled: Boolean(upCd && lwpoCd),
staleTime: CODE_STALE_TIME,
});
}
+6
View File
@@ -0,0 +1,6 @@
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";
@@ -0,0 +1,7 @@
import type { Code } from "../model/code-interface";
export function findCodeLabel(codes: Code[] | undefined, lwpoCd?: string): string {
if (!lwpoCd) return "";
if (!codes?.length) return lwpoCd;
return codes.find((c) => c.lwpoCd === lwpoCd)?.lwpoCdNm ?? lwpoCd;
}
@@ -0,0 +1,5 @@
/** 결재선 관리 SPEC 상위코드 */
export const UP_CD = {
DOC_SE: "ASDOCSECD",
ATRZ_SE: "ATRZSECD",
} as const;
@@ -0,0 +1,14 @@
/** cms-service CodeResponseDto */
export interface Code {
lwpoCd: string;
upCd: string;
lwpoCdNm: string;
unit?: string;
prntLwpoCd?: string;
}
export interface CodeService {
getList(upCd: string): Promise<Code[]>;
getListByUseYn(upCd: string, useYn: string): Promise<Code[]>;
getCodeName(upCd: string, lwpoCd: string): Promise<string>;
}
@@ -0,0 +1,50 @@
"use client";
import {
cn,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { useCodeList, useCodeListByUseYn } from "../api/queries";
interface CodeSelectProps extends React.ComponentPropsWithoutRef<typeof Select> {
upCd: string;
useYn?: string;
placeholder?: string;
className?: string;
}
function CodeSelect({
upCd,
useYn,
placeholder = "선택",
className,
...props
}: CodeSelectProps) {
const listQuery = useCodeList(useYn ? undefined : upCd);
const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? "");
const codes = useYn ? listByUseYnQuery.data : listQuery.data;
const isLoading = useYn ? listByUseYnQuery.isPending : listQuery.isPending;
return (
<Select {...props} disabled={props.disabled || isLoading}>
<SelectTrigger className={cn("w-[12rem]", className)}>
<SelectValue placeholder={isLoading ? "로딩 중…" : placeholder} />
</SelectTrigger>
<SelectContent>
{codes?.map((item) => (
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
{item.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export { CodeSelect };
@@ -0,0 +1,51 @@
import { client } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
ElaprApln,
ElaprAplnListParams,
ElaprAplnSaveRequest,
ElaprAplnService,
ElaprAplnUpdateRequest,
} from "../model/elapr-apln-interface";
async function findPage(params: ElaprAplnListParams): Promise<PageResponse<ElaprApln>> {
const response = await client.get<PageResponse<ElaprApln>>(
ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN,
{ params },
);
return response.data;
}
async function findById(aplnId: string): Promise<ElaprApln> {
const response = await client.get<ElaprApln>(
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`,
);
return response.data;
}
async function create(body: ElaprAplnSaveRequest): Promise<ElaprApln> {
const response = await client.post<ElaprApln>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, body);
return response.data;
}
async function update(aplnId: string, body: ElaprAplnUpdateRequest): Promise<ElaprApln> {
const response = await client.put<ElaprApln>(
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`,
body,
);
return response.data;
}
async function remove(aplnId: string): Promise<void> {
await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`);
}
export const elaprAplnService = {
findPage,
findById,
create,
update,
remove,
} satisfies ElaprAplnService;
@@ -0,0 +1,57 @@
import { client } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
ElaprTmplt,
ElaprTmpltSaveRequest,
ElaprTmpltSearchParams,
ElaprTmpltService,
ElaprTmpltUpdateRequest,
} from "../model/elapr-tmplt-interface";
async function findPage(params: ElaprTmpltSearchParams): Promise<PageResponse<ElaprTmplt>> {
const response = await client.get<PageResponse<ElaprTmplt>>(
ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT,
{ params },
);
return response.data;
}
async function findById(elaprTmpltId: string): Promise<ElaprTmplt> {
const response = await client.get<ElaprTmplt>(
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`,
);
return response.data;
}
async function create(body: ElaprTmpltSaveRequest): Promise<ElaprTmplt> {
const response = await client.post<ElaprTmplt>(
ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT,
body,
);
return response.data;
}
async function update(
elaprTmpltId: string,
body: ElaprTmpltUpdateRequest,
): Promise<ElaprTmplt> {
const response = await client.put<ElaprTmplt>(
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`,
body,
);
return response.data;
}
async function remove(elaprTmpltId: string): Promise<void> {
await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`);
}
export const elaprTmpltService = {
findPage,
findById,
create,
update,
remove,
} satisfies ElaprTmpltService;
+151
View File
@@ -0,0 +1,151 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
ElaprAplnListParams,
ElaprAplnSaveRequest,
ElaprAplnUpdateRequest,
} from "../model/elapr-apln-interface";
import type {
ElaprTmpltSaveRequest,
ElaprTmpltSearchParams,
ElaprTmpltUpdateRequest,
} from "../model/elapr-tmplt-interface";
import { elaprAplnService } from "./elapr-apln-service";
import { elaprTmpltService } from "./elapr-tmplt-service";
const elaprTmpltBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT] as const;
const elaprAplnBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN] as const;
export function useElaprTmpltPage(params: ElaprTmpltSearchParams) {
return useQuery({
queryKey: [...elaprTmpltBaseKey, "page", params],
queryFn: () => elaprTmpltService.findPage(params),
});
}
export function useElaprTmplt(elaprTmpltId: string | null | undefined) {
return useQuery({
queryKey: [...elaprTmpltBaseKey, elaprTmpltId],
queryFn: () => elaprTmpltService.findById(elaprTmpltId!),
enabled: Boolean(elaprTmpltId),
});
}
export function useCreateElaprTmplt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: ElaprTmpltSaveRequest) => elaprTmpltService.create(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey });
},
});
}
export function useUpdateElaprTmplt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
elaprTmpltId,
body,
}: {
elaprTmpltId: string;
body: ElaprTmpltUpdateRequest;
}) => elaprTmpltService.update(elaprTmpltId, body),
onSuccess: (_data, { elaprTmpltId }) => {
queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey });
queryClient.invalidateQueries({
queryKey: [...elaprTmpltBaseKey, elaprTmpltId],
});
},
});
}
export function useDeleteElaprTmplt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (elaprTmpltId: string) => elaprTmpltService.remove(elaprTmpltId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey });
},
});
}
export function useElaprAplnPage(params: ElaprAplnListParams) {
const { elaprTmpltId } = params;
return useQuery({
queryKey: [...elaprAplnBaseKey, "page", params],
queryFn: () => elaprAplnService.findPage(params),
enabled: Boolean(elaprTmpltId),
});
}
export function useElaprApln(aplnId: string | null | undefined) {
return useQuery({
queryKey: [...elaprAplnBaseKey, aplnId],
queryFn: () => elaprAplnService.findById(aplnId!),
enabled: Boolean(aplnId),
});
}
export function useCreateElaprApln() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: ElaprAplnSaveRequest) => elaprAplnService.create(body),
onSuccess: (_data, { elaprTmpltId }) => {
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
queryClient.invalidateQueries({
queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }],
});
},
});
}
export function useUpdateElaprApln() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
aplnId,
body,
}: {
aplnId: string;
body: ElaprAplnUpdateRequest;
}) => elaprAplnService.update(aplnId, body),
onSuccess: (_data, { body, aplnId }) => {
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
queryClient.invalidateQueries({
queryKey: [...elaprAplnBaseKey, aplnId],
});
queryClient.invalidateQueries({
queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId: body.elaprTmpltId }],
});
},
});
}
export function useDeleteElaprApln() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
aplnId,
}: {
aplnId: string;
elaprTmpltId: string;
}) => elaprAplnService.remove(aplnId),
onSuccess: (_data, { elaprTmpltId }) => {
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
queryClient.invalidateQueries({
queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }],
});
},
});
}
+28
View File
@@ -0,0 +1,28 @@
export { elaprAplnService } from "./api/elapr-apln-service";
export { elaprTmpltService } from "./api/elapr-tmplt-service";
export {
useCreateElaprApln,
useCreateElaprTmplt,
useDeleteElaprApln,
useDeleteElaprTmplt,
useElaprApln,
useElaprAplnPage,
useElaprTmplt,
useElaprTmpltPage,
useUpdateElaprApln,
useUpdateElaprTmplt,
} from "./api/queries";
export type {
ElaprApln,
ElaprAplnListParams,
ElaprAplnSaveRequest,
ElaprAplnService,
ElaprAplnUpdateRequest,
} from "./model/elapr-apln-interface";
export type {
ElaprTmplt,
ElaprTmpltSaveRequest,
ElaprTmpltSearchParams,
ElaprTmpltService,
ElaprTmpltUpdateRequest,
} from "./model/elapr-tmplt-interface";
@@ -0,0 +1,41 @@
import type { PageableParams, PageResponse } from "@/shared/model";
export interface ElaprApln {
aplnId: string;
elaprTmpltId: string;
aprvrUuid?: string;
atrzSeComCd?: string;
atrzSeComNm?: string;
atrzSeqNo?: number;
regDt?: string;
rgtrUuid?: string;
mdfcnDt?: string;
mdfrUuid?: string;
}
export interface ElaprAplnListParams extends PageableParams {
elaprTmpltId?: string;
}
export interface ElaprAplnSaveRequest {
aplnId: string;
elaprTmpltId: string;
aprvrUuid?: string;
atrzSeComCd?: string;
atrzSeqNo?: number;
}
export interface ElaprAplnUpdateRequest {
elaprTmpltId: string;
aprvrUuid?: string;
atrzSeComCd?: string;
atrzSeqNo?: number;
}
export interface ElaprAplnService {
findPage(params: ElaprAplnListParams): Promise<PageResponse<ElaprApln>>;
findById(aplnId: string): Promise<ElaprApln>;
create(body: ElaprAplnSaveRequest): Promise<ElaprApln>;
update(aplnId: string, body: ElaprAplnUpdateRequest): Promise<ElaprApln>;
remove(aplnId: string): Promise<void>;
}
@@ -0,0 +1,41 @@
import type { PageableParams, PageResponse } from "@/shared/model";
export interface ElaprTmplt {
elaprTmpltId: string;
elaprTmpltNm: string;
elaprTmpltExpln?: string;
docSeComCd?: string;
docSeComNm?: string;
regDt?: string;
rgtrUuid?: string;
mdfcnDt?: string;
mdfrUuid?: string;
}
export interface ElaprTmpltSearchParams extends PageableParams {
elaprTmpltNm?: string;
docSeComCd?: string;
regDtStart?: string;
regDtEnd?: string;
}
export interface ElaprTmpltSaveRequest {
elaprTmpltId: string;
elaprTmpltNm: string;
elaprTmpltExpln?: string;
docSeComCd?: string;
}
export interface ElaprTmpltUpdateRequest {
elaprTmpltNm: string;
elaprTmpltExpln?: string;
docSeComCd?: string;
}
export interface ElaprTmpltService {
findPage(params: ElaprTmpltSearchParams): Promise<PageResponse<ElaprTmplt>>;
findById(elaprTmpltId: string): Promise<ElaprTmplt>;
create(body: ElaprTmpltSaveRequest): Promise<ElaprTmplt>;
update(elaprTmpltId: string, body: ElaprTmpltUpdateRequest): Promise<ElaprTmplt>;
remove(elaprTmpltId: string): Promise<void>;
}
@@ -0,0 +1,2 @@
export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog";
export type { ReviewerOption } from "./model/mock-reviewers";
@@ -0,0 +1,17 @@
import { fakerKO as faker } from "@faker-js/faker";
export interface ReviewerOption {
uuid: string;
name: string;
deptNm: string;
}
/** TBD: 검토인력 목록 API 연동 전 목 데이터 */
export function createMockReviewers(count = 12): ReviewerOption[] {
faker.seed(42);
return Array.from({ length: count }, () => ({
uuid: faker.string.uuid(),
name: faker.person.fullName(),
deptNm: faker.commerce.department(),
}));
}
@@ -0,0 +1,99 @@
"use client";
import { useMemo, useState } from "react";
import {
Button,
DataTable,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
} from "@common/components";
import {
createColumnHelper,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import type { ReviewerOption } from "../model/mock-reviewers";
import { createMockReviewers } from "../model/mock-reviewers";
interface AprvrPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (reviewer: ReviewerOption) => void;
}
const columnHelper = createColumnHelper<ReviewerOption>();
function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) {
const [keyword, setKeyword] = useState("");
const reviewers = useMemo(() => createMockReviewers(), []);
const filtered = useMemo(() => {
const q = keyword.trim().toLowerCase();
if (!q) return reviewers;
return reviewers.filter(
(r) =>
r.name.toLowerCase().includes(q) || r.deptNm.toLowerCase().includes(q),
);
}, [keyword, reviewers]);
const columns = useMemo(
() => [
columnHelper.accessor("name", { header: "성명" }),
columnHelper.accessor("deptNm", { header: "부서" }),
columnHelper.display({
id: "actions",
header: "선택",
cell: ({ row }) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
onSelect(row.original);
onOpenChange(false);
}}
>
선택
</Button>
),
}),
],
[onOpenChange, onSelect],
);
const table = useReactTable({
data: filtered,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>검토인력 선택</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="성명·부서 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<DataTable table={table} variant="line" />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { AprvrPickerDialog };
@@ -0,0 +1,2 @@
export { ElaprTmpltFormDialog } from "./ui/elapr-tmplt-form-dialog";
export type { ElaprTmpltFormValues } from "./ui/elapr-tmplt-form-dialog";
@@ -0,0 +1,127 @@
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
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;
docSeComCd: string;
elaprTmpltExpln: string;
}
interface ElaprTmpltFormDialogProps {
mode: "create" | "edit";
open: boolean;
onOpenChange: (open: boolean) => void;
initial?: ElaprTmplt | null;
isSubmitting?: boolean;
onSubmit: (values: ElaprTmpltFormValues) => void;
}
function ElaprTmpltFormDialog({
mode,
open,
onOpenChange,
initial,
isSubmitting,
onSubmit,
}: ElaprTmpltFormDialogProps) {
const { register, handleSubmit, reset, setValue, watch } = useForm<ElaprTmpltFormValues>({
defaultValues: {
elaprTmpltId: "",
elaprTmpltNm: "",
docSeComCd: "",
elaprTmpltExpln: "",
},
});
const docSeComCd = watch("docSeComCd");
useEffect(() => {
if (!open) return;
if (mode === "edit" && initial) {
reset({
elaprTmpltId: initial.elaprTmpltId,
elaprTmpltNm: initial.elaprTmpltNm,
docSeComCd: initial.docSeComCd ?? "",
elaprTmpltExpln: initial.elaprTmpltExpln ?? "",
});
return;
}
reset({
// TBD: elaprTmpltId 발급 정책 — 현재 클라이언트 임시 ID
elaprTmpltId: generateClientId("TMPL"),
elaprTmpltNm: "",
docSeComCd: "",
elaprTmpltExpln: "",
});
}, [open, mode, initial, reset]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<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 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="elaprTmpltNm">결재서식명</Label>
<Input
id="elaprTmpltNm"
{...register("elaprTmpltNm", { required: true })}
/>
</div>
<div className="space-y-2">
<Label>문서구분</Label>
<CodeSelect
upCd={UP_CD.DOC_SE}
value={docSeComCd}
onValueChange={(v) => setValue("docSeComCd", v)}
placeholder="문서구분 선택"
/>
</div>
<div className="space-y-2">
<Label htmlFor="elaprTmpltExpln">서식 설명</Label>
<Textarea id="elaprTmpltExpln" rows={3} {...register("elaprTmpltExpln")} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button type="submit" disabled={isSubmitting}>
{mode === "create" ? "등록" : "저장"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export { ElaprTmpltFormDialog };
@@ -0,0 +1 @@
export { ApprovalLineManagementPage } from "./ui/approval-line-management-page";
@@ -0,0 +1,29 @@
"use client";
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>
);
}
export { ApprovalLineManagementPage };
+7
View File
@@ -4,6 +4,13 @@ export const ENDPOINT = {
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
USERS: "/user-service/api/v1/users",
},
AS_ELAPR_SERVICE: {
ELAPR_TMPLT: "/as-elapr-service/api/v1/elapr-tmplt",
ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln",
},
CMS_SERVICE: {
CODE: "/cms-service/api/v1/code/cache",
},
};
export const JWT_EXPIRATION_TIME = 1800000;
@@ -0,0 +1,5 @@
/** TBD: elaprTmpltId / aplnId 서버 발급 정책 확정 후 클라이언트 생성 규칙 교체 */
export function generateClientId(prefix: string): string {
const suffix = crypto.randomUUID().replace(/-/g, "").slice(0, 8).toUpperCase();
return `${prefix}-${suffix}`;
}
+3
View File
@@ -2,3 +2,6 @@ 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 { useDataTable } from "./use-data-table";
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import type { UseQueryResult } from "@tanstack/react-query";
interface MatchQueryStatusOptions {
Pending: ReactNode;
Empty: ReactNode;
Success: () => ReactNode;
Error?: ReactNode;
}
export function matchQueryStatus<T>(
query: UseQueryResult<T>,
options: MatchQueryStatusOptions,
): ReactNode {
if (query.isPending) return options.Pending;
if (query.isError) return options.Error ?? null;
const isEmpty =
query.data == null ||
(Array.isArray(query.data) && query.data.length === 0) ||
(typeof query.data === "object" &&
query.data !== null &&
"content" in query.data &&
Array.isArray((query.data as { content: unknown[] }).content) &&
(query.data as { content: unknown[] }).content.length === 0);
if (isEmpty) return options.Empty;
return options.Success();
}
+43
View File
@@ -0,0 +1,43 @@
import type { HTMLAttributes } from "react";
import type { UseQueryResult } from "@tanstack/react-query";
import { useReactTable, type TableOptions } from "@tanstack/react-table";
import { DataTable } from "@common/components";
import type { PageResponse } from "@/shared/model";
import { NoData } from "@/shared/ui/no-data";
import { matchQueryStatus } from "./match-query-status";
interface DataTableOptions<T> {
variant?: "default" | "secondary" | "line";
className?: HTMLAttributes<HTMLTableElement>["className"];
viewPagination?: boolean;
onRowClick?: (row: T) => void;
}
interface UseDataTableOptions<T> {
query: UseQueryResult<PageResponse<T>>;
tableOptions: Omit<TableOptions<T>, "data" | "rowCount">;
dataTableOptions?: DataTableOptions<T>;
}
export function useDataTable<T>({
query,
tableOptions,
dataTableOptions,
}: UseDataTableOptions<T>) {
const table = useReactTable({
...tableOptions,
data: query.data?.content ?? [],
rowCount: query.data?.totalElements ?? 0,
});
return {
tableComponent: matchQueryStatus(query, {
Pending: <p className="py-8 text-center text-sm text-muted-foreground">로딩 중…</p>,
Empty: <NoData />,
Success: () => <DataTable table={table} {...dataTableOptions} />,
}),
tableInstance: table,
};
}
+2
View File
@@ -27,3 +27,5 @@ export interface ApiError {
code?: string;
status?: number;
}
export type { PageableParams, PageResponse } from "./page";
+32
View File
@@ -0,0 +1,32 @@
interface SortConfig {
empty: boolean;
sorted: boolean;
unsorted: boolean;
}
export interface PageableParams {
page?: number;
size?: number;
sort?: string;
}
export interface PageResponse<T> {
content: T[];
pageable: {
sort: SortConfig;
offset: number;
pageNumber: number;
pageSize: number;
paged: boolean;
unpaged: boolean;
};
sort: SortConfig;
totalPages: number;
totalElements: number;
number: number;
numberOfElements: number;
size: number;
first: boolean;
last: boolean;
empty: boolean;
}
+1
View File
@@ -1 +1,2 @@
export { PageLayout } from "./page-layout";
export { NoData } from "./no-data";
+9
View File
@@ -0,0 +1,9 @@
function NoData({ message = "조회된 데이터가 없습니다." }: { message?: string }) {
return (
<div className="flex min-h-[120px] items-center justify-center text-muted-foreground">
<p className="text-sm">{message}</p>
</div>
);
}
export { NoData };
@@ -0,0 +1 @@
export { ElaprAplnGrid } from "./ui/elapr-apln-grid";
@@ -0,0 +1,148 @@
"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 { ElaprAplnRow } from "../model/types";
function toRow(item: ElaprApln): ElaprAplnRow {
return { ...item, isNew: false, isDirty: false };
}
interface UseAplnRowsOptions {
elaprTmpltId: string | null;
serverRows: ElaprApln[] | undefined;
}
export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
const [rows, setRows] = useState<ElaprAplnRow[]>([]);
const [editingRowId, setEditingRowId] = useState<string | null>(null);
useEffect(() => {
if (!elaprTmpltId) {
setRows([]);
setEditingRowId(null);
return;
}
setRows((serverRows ?? []).map(toRow));
setEditingRowId(null);
}, [elaprTmpltId, serverRows]);
const isEditingLocked = editingRowId != null;
const addRow = useCallback(() => {
if (!elaprTmpltId || isEditingLocked) return;
const tempId = generateClientId("APLN");
const nextSeq = rows.length + 1;
setRows((prev) => [
...prev,
{
aplnId: tempId,
elaprTmpltId,
atrzSeqNo: nextSeq,
atrzSeComCd: "",
aprvrUuid: "",
aprvrNm: "",
isNew: true,
isDirty: true,
},
]);
setEditingRowId(tempId);
}, [elaprTmpltId, isEditingLocked, rows.length]);
const startEdit = useCallback(
(aplnId: string) => {
if (isEditingLocked) return;
setEditingRowId(aplnId);
setRows((prev) =>
prev.map((row) =>
row.aplnId === aplnId
? { ...row, snapshot: { ...row } }
: row,
),
);
},
[isEditingLocked],
);
const updateRow = useCallback(
(aplnId: string, patch: Partial<ElaprAplnRow>) => {
if (editingRowId !== aplnId) return;
setRows((prev) =>
prev.map((row) =>
row.aplnId === aplnId
? { ...row, ...patch, isDirty: true }
: row,
),
);
},
[editingRowId],
);
const cancelEdit = useCallback(() => {
if (!editingRowId) return;
setRows((prev) => {
const target = prev.find((r) => r.aplnId === editingRowId);
if (target?.isNew) {
return prev.filter((r) => r.aplnId !== editingRowId);
}
return prev.map((row) => {
if (row.aplnId !== editingRowId) return row;
if (row.snapshot) {
const { snapshot: _s, isNew: _n, isDirty: _d, ...restored } = row.snapshot;
return { ...restored, isNew: false, isDirty: false };
}
return { ...row, isDirty: false };
});
});
setEditingRowId(null);
}, [editingRowId]);
const clearEditing = useCallback(() => {
setEditingRowId(null);
}, []);
const removeRowLocal = useCallback((aplnId: string) => {
setRows((prev) => prev.filter((r) => r.aplnId !== aplnId));
setEditingRowId((id) => (id === aplnId ? null : id));
}, []);
const reorderRows = useCallback(
(activeId: string, overId: string) => {
if (isEditingLocked) return;
setRows((prev) => {
const oldIndex = prev.findIndex((r) => r.aplnId === activeId);
const newIndex = prev.findIndex((r) => r.aplnId === overId);
if (oldIndex < 0 || newIndex < 0) return prev;
const moved = arrayMove(prev, oldIndex, newIndex).map((row, idx) => ({
...row,
atrzSeqNo: idx + 1,
isDirty: true,
}));
return moved;
});
},
[isEditingLocked],
);
return {
rows,
setRows,
editingRowId,
isEditingLocked,
addRow,
startEdit,
updateRow,
cancelEdit,
clearEditing,
removeRowLocal,
reorderRows,
};
}
@@ -0,0 +1,16 @@
import {
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
export function useDndSensors() {
return useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
}
@@ -0,0 +1,26 @@
import type { ElaprAplnRow } from "../model/types";
export function validateAplnRow(row: ElaprAplnRow): string | null {
if (row.atrzSeqNo == null || Number.isNaN(row.atrzSeqNo)) {
return "결재순서번호를 입력해 주세요.";
}
if (!row.atrzSeComCd?.trim()) {
return "결재구분을 선택해 주세요.";
}
if (!row.aprvrUuid?.trim()) {
return "결재자를 선택해 주세요.";
}
return null;
}
export function validateUniqueSeqNo(rows: ElaprAplnRow[]): string | null {
const seqSet = new Set<number>();
for (const row of rows) {
if (row.atrzSeqNo == null) continue;
if (seqSet.has(row.atrzSeqNo)) {
return "동일 서식 내 결재순서번호가 중복됩니다.";
}
seqSet.add(row.atrzSeqNo);
}
return null;
}
@@ -0,0 +1,11 @@
import type { ElaprApln } from "@/entities/elapr";
/** 그리드 로컬 행 — API 응답 + UI 보조 필드 */
export interface ElaprAplnRow extends ElaprApln {
/** 결재자 표시명 — 응답 DTO에 없음, 선택 시 로컬 유지 */
aprvrNm?: string;
isNew?: boolean;
isDirty?: boolean;
/** 수정 취소용 스냅샷 */
snapshot?: ElaprAplnRow;
}
@@ -0,0 +1,278 @@
"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 {
useCreateElaprApln,
useDeleteElaprApln,
useElaprAplnPage,
useUpdateElaprApln,
} from "@/entities/elapr";
import { AprvrPickerDialog, type ReviewerOption } from "@/features/aprvr-picker";
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 {
elaprTmpltId: string | null;
}
function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [pickingRowId, setPickingRowId] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const aplnQuery = useElaprAplnPage({
elaprTmpltId: elaprTmpltId ?? undefined,
page: 0,
size: 200,
sort: "atrzSeqNo,asc",
});
const createMutation = useCreateElaprApln();
const updateMutation = useUpdateElaprApln();
const deleteMutation = useDeleteElaprApln();
const {
rows,
editingRowId,
isEditingLocked,
addRow,
startEdit,
updateRow,
cancelEdit,
clearEditing,
removeRowLocal,
reorderRows,
} = useAplnRows({
elaprTmpltId,
serverRows: aplnQuery.data?.content,
});
const sensors = useDndSensors();
const isDisabled = !elaprTmpltId;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
reorderRows(String(active.id), String(over.id));
};
const handleRowSave = async (row: ElaprAplnRow) => {
const fieldError = validateAplnRow(row);
if (fieldError) {
setStatusMessage(fieldError);
return;
}
const dupError = validateUniqueSeqNo(
rows.map((r) => (r.aplnId === row.aplnId ? row : r)),
);
if (dupError) {
setStatusMessage(dupError);
return;
}
if (!elaprTmpltId) return;
if (row.isNew) {
await createMutation.mutateAsync({
aplnId: row.aplnId,
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
});
} else {
await updateMutation.mutateAsync({
aplnId: row.aplnId,
body: {
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
},
});
}
clearEditing();
setStatusMessage(null);
await aplnQuery.refetch();
};
const handleRowDelete = async (row: ElaprAplnRow) => {
if (!window.confirm("선택한 결재선을 삭제하시겠습니까?")) return;
if (row.isNew) {
removeRowLocal(row.aplnId);
return;
}
if (!elaprTmpltId) return;
await deleteMutation.mutateAsync({ aplnId: row.aplnId, elaprTmpltId });
await aplnQuery.refetch();
};
const handleBulkSave = async () => {
if (!elaprTmpltId || isEditingLocked) return;
const dupError = validateUniqueSeqNo(rows);
if (dupError) {
setStatusMessage(dupError);
return;
}
const dirtyRows = rows.filter((r) => r.isDirty || r.isNew);
for (const row of dirtyRows) {
const fieldError = validateAplnRow(row);
if (fieldError) {
setStatusMessage(fieldError);
return;
}
if (row.isNew) {
await createMutation.mutateAsync({
aplnId: row.aplnId,
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
});
} else {
await updateMutation.mutateAsync({
aplnId: row.aplnId,
body: {
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
},
});
}
}
setStatusMessage("결재선이 저장되었습니다.");
await aplnQuery.refetch();
};
const handleReviewerSelect = (reviewer: ReviewerOption) => {
if (!pickingRowId) return;
updateRow(pickingRowId, {
aprvrUuid: reviewer.uuid,
aprvrNm: reviewer.name,
});
setPickingRowId(null);
};
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>
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-muted-foreground">
{elaprTmpltId
? `선택된 서식: ${elaprTmpltId}`
: "서식을 선택하면 결재선을 관리할 수 있습니다."}
</span>
<Button
type="button"
variant="outline"
disabled={isDisabled || isEditingLocked}
onClick={addRow}
>
행 추가
</Button>
<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>
) : !elaprTmpltId ? (
<p className="py-8 text-center text-sm text-muted-foreground">
상단 목록에서 결재서식을 선택해 주세요.
</p>
) : rows.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
등록된 결재선이 없습니다. [행 추가]로 결재자를 등록하세요.
</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table variant="line" className="w-full">
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>결재선아이디</TableHead>
<TableHead>결재순서</TableHead>
<TableHead>결재구분</TableHead>
<TableHead>결재자</TableHead>
<TableHead>등록일시</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<tbody>
<SortableContext
items={rows.map((r) => r.aplnId)}
strategy={verticalListSortingStrategy}
>
{rows.map((row) => (
<SortableAplnRow
key={row.aplnId}
row={row}
isEditing={editingRowId === row.aplnId}
isLocked={isEditingLocked}
dragDisabled={isEditingLocked}
onStartEdit={() => startEdit(row.aplnId)}
onSave={() => handleRowSave(row)}
onCancel={cancelEdit}
onDelete={() => handleRowDelete(row)}
onChange={(patch) => updateRow(row.aplnId, patch)}
onPickAprvr={() => {
setPickingRowId(row.aplnId);
setPickerOpen(true);
}}
/>
))}
</SortableContext>
</tbody>
</Table>
</DndContext>
)}
<AprvrPickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
onSelect={handleReviewerSelect}
/>
</section>
);
}
export { ElaprAplnGrid };
@@ -0,0 +1,165 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Button,
Input,
TableCell,
TableRow,
} from "@common/components";
import { CodeSelect, findCodeLabel, UP_CD, useCodeList } from "@/entities/code";
import type { ElaprAplnRow } from "../model/types";
interface SortableAplnRowProps {
row: ElaprAplnRow;
isEditing: boolean;
isLocked: boolean;
dragDisabled: boolean;
onStartEdit: () => void;
onSave: () => void;
onCancel: () => void;
onDelete: () => void;
onChange: (patch: Partial<ElaprAplnRow>) => void;
onPickAprvr: () => void;
}
function SortableAplnRow({
row,
isEditing,
isLocked,
dragDisabled,
onStartEdit,
onSave,
onCancel,
onDelete,
onChange,
onPickAprvr,
}: SortableAplnRowProps) {
const { data: atrzSeCodes } = useCodeList(UP_CD.ATRZ_SE);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: row.aplnId, disabled: dragDisabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const rowDisabled = isLocked && !isEditing;
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
type="button"
className="cursor-grab disabled:cursor-not-allowed disabled:opacity-40"
disabled={dragDisabled}
{...attributes}
{...listeners}
aria-label="순서 변경"
>
<span className="select-none text-muted-foreground" aria-hidden>
⋮⋮
</span>
</button>
</TableCell>
<TableCell align="left">{row.aplnId}</TableCell>
<TableCell align="center">
{isEditing ? (
<Input
type="number"
className="w-20"
min={1}
value={row.atrzSeqNo ?? ""}
onChange={(e) =>
onChange({ atrzSeqNo: Number.parseInt(e.target.value, 10) || undefined })
}
/>
) : (
(row.atrzSeqNo ?? "—")
)}
</TableCell>
<TableCell align="center">
{isEditing ? (
<CodeSelect
upCd={UP_CD.ATRZ_SE}
className="min-w-[120px]"
value={row.atrzSeComCd ?? ""}
onValueChange={(v) =>
onChange({
atrzSeComCd: v,
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
})
}
placeholder="결재구분"
/>
) : (
atrzSeLabel || "—"
)}
</TableCell>
<TableCell align="left">
<div className="flex items-center gap-2">
<span className="min-w-[80px] text-sm">
{row.aprvrNm || row.aprvrUuid || "—"}
</span>
{isEditing && (
<Button type="button" size="sm" variant="outline" onClick={onPickAprvr}>
선택
</Button>
)}
</div>
</TableCell>
<TableCell align="center">{row.isNew ? "—" : (row.regDt ?? "—")}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
{isEditing ? (
<>
<Button type="button" size="sm" onClick={onSave}>
저장
</Button>
<Button type="button" size="sm" variant="outline" onClick={onCancel}>
취소
</Button>
</>
) : (
<>
<Button
type="button"
size="sm"
variant="outline"
disabled={rowDisabled}
onClick={onStartEdit}
>
수정
</Button>
<Button
type="button"
size="sm"
variant="outline"
color="red"
disabled={rowDisabled}
onClick={onDelete}
>
삭제
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
);
}
export { SortableAplnRow };
@@ -0,0 +1 @@
export { ElaprTmpltList } from "./ui/elapr-tmplt-list";
@@ -0,0 +1,57 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import { Button } from "@common/components";
import type { ElaprTmplt } from "@/entities/elapr";
const columnHelper = createColumnHelper<ElaprTmplt>();
interface CreateTmpltColumnsOptions {
onEdit: (row: ElaprTmplt) => void;
onDelete: (row: ElaprTmplt) => void;
}
export function createTmpltColumns({ onEdit, onDelete }: CreateTmpltColumnsOptions) {
return [
columnHelper.accessor("elaprTmpltId", {
header: "전자결재서식아이디",
meta: { align: "left" },
}),
columnHelper.accessor("elaprTmpltNm", {
header: "결재서식명",
meta: { align: "left" },
}),
columnHelper.accessor("docSeComNm", {
header: "문서구분",
cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "—",
}),
columnHelper.accessor("regDt", {
header: "등록일시",
cell: ({ getValue }) => getValue() ?? "—",
}),
columnHelper.display({
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)}>
수정
</Button>
<Button
type="button"
size="sm"
variant="outline"
color="red"
onClick={() => onDelete(row.original)}
>
삭제
</Button>
</div>
),
}),
];
}
@@ -0,0 +1,143 @@
"use client";
import { useMemo, useState } from "react";
import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
import { Button } from "@common/components";
import {
useCreateElaprTmplt,
useDeleteElaprTmplt,
useElaprTmpltPage,
useUpdateElaprTmplt,
type ElaprTmplt,
} from "@/entities/elapr";
import {
ElaprTmpltFormDialog,
type ElaprTmpltFormValues,
} from "@/features/elapr-tmplt-dialog";
import { useDataTable } from "@/shared/lib";
import { createTmpltColumns } from "../model/columns";
interface ElaprTmpltListProps {
selectedTmpltId: string | null;
onSelectTmplt: (tmplt: ElaprTmplt | null) => void;
}
function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps) {
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
const [editingTmplt, setEditingTmplt] = useState<ElaprTmplt | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const tmpltQuery = useElaprTmpltPage({
page: 0,
size: 50,
sort: "regDt,desc",
});
const createMutation = useCreateElaprTmplt();
const updateMutation = useUpdateElaprTmplt();
const deleteMutation = useDeleteElaprTmplt();
const columns = useMemo(
() =>
createTmpltColumns({
onEdit: (row) => {
setEditingTmplt(row);
setDialogMode("edit");
},
onDelete: async (row) => {
if (!window.confirm("선택한 결재서식을 삭제하시겠습니까?")) return;
await deleteMutation.mutateAsync(row.elaprTmpltId);
if (selectedTmpltId === row.elaprTmpltId) {
onSelectTmplt(null);
}
},
}),
[deleteMutation, onSelectTmplt, selectedTmpltId],
);
const { tableComponent } = useDataTable({
query: tmpltQuery,
tableOptions: {
columns,
state: { rowSelection },
enableRowSelection: true,
enableMultiRowSelection: false,
getRowId: (row) => row.elaprTmpltId,
onRowSelectionChange: (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,
);
onSelectTmplt(selected ?? null);
},
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
onRowClick: (row) => {
setRowSelection({ [row.elaprTmpltId]: true });
onSelectTmplt(row);
},
},
});
const handleFormSubmit = async (values: ElaprTmpltFormValues) => {
if (dialogMode === "create") {
await createMutation.mutateAsync({
elaprTmpltId: values.elaprTmpltId,
elaprTmpltNm: values.elaprTmpltNm,
docSeComCd: values.docSeComCd || undefined,
elaprTmpltExpln: values.elaprTmpltExpln || undefined,
});
} else if (dialogMode === "edit" && editingTmplt) {
await updateMutation.mutateAsync({
elaprTmpltId: editingTmplt.elaprTmpltId,
body: {
elaprTmpltNm: values.elaprTmpltNm,
docSeComCd: values.docSeComCd || undefined,
elaprTmpltExpln: values.elaprTmpltExpln || undefined,
},
});
}
setDialogMode(null);
setEditingTmplt(null);
};
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>
<Button type="button" onClick={() => setDialogMode("create")}>
서식 등록
</Button>
</div>
{tableComponent}
{dialogMode && (
<ElaprTmpltFormDialog
mode={dialogMode}
open={Boolean(dialogMode)}
onOpenChange={(open) => {
if (!open) {
setDialogMode(null);
setEditingTmplt(null);
}
}}
initial={dialogMode === "edit" ? editingTmplt : null}
isSubmitting={createMutation.isPending || updateMutation.isPending}
onSubmit={handleFormSubmit}
/>
)}
</section>
);
}
export { ElaprTmpltList };