[as] feat: 필수첨부파일관리 페이지 초안 작업
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
EsntlAtchFileMng,
|
||||
EsntlAtchFileMngSaveRequest,
|
||||
EsntlAtchFileMngSearchParams,
|
||||
EsntlAtchFileMngService,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
EsntlAtchFileMngTreeSearchParams,
|
||||
} from "../model/esntl-interface";
|
||||
|
||||
async function findTree(
|
||||
params: EsntlAtchFileMngSearchParams,
|
||||
params: EsntlAtchFileMngTreeSearchParams,
|
||||
): Promise<EsntlAtchFileMngTreeResponseDto[]> {
|
||||
const response = await client.get<EsntlAtchFileMngTreeResponseDto[]>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`,
|
||||
@@ -17,6 +21,48 @@ async function findTree(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findPage(
|
||||
params: EsntlAtchFileMngSearchParams,
|
||||
): Promise<PageResponse<EsntlAtchFileMng>> {
|
||||
const response = await client.get<PageResponse<EsntlAtchFileMng>>(
|
||||
ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng> {
|
||||
const response = await client.get<EsntlAtchFileMng>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: EsntlAtchFileMngSaveRequest): Promise<string> {
|
||||
const response = await client.post<string>(ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string> {
|
||||
const response = await client.put<string>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(esntlAtchFileId: string): Promise<string> {
|
||||
const response = await client.delete<string>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const esntlAtchFileMngService = {
|
||||
findTree,
|
||||
findPage,
|
||||
findById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
} satisfies EsntlAtchFileMngService;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
EsntlAtchFileMngSaveRequest,
|
||||
EsntlAtchFileMngSearchParams,
|
||||
EsntlAtchFileMngTreeSearchParams,
|
||||
} from "../model/esntl-interface";
|
||||
|
||||
import { esntlAtchFileMngService } from "./esntl-service";
|
||||
|
||||
const esntlAtchFileMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE] as const;
|
||||
|
||||
export function useEsntlAtchFileMngTree(params: EsntlAtchFileMngTreeSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: [...esntlAtchFileMngBaseKey, "tree", params],
|
||||
queryFn: () => esntlAtchFileMngService.findTree(params),
|
||||
enabled: Boolean(params.docSeComCd),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEsntlAtchFileMngPage(params: EsntlAtchFileMngSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: [...esntlAtchFileMngBaseKey, "page", params],
|
||||
queryFn: () => esntlAtchFileMngService.findPage(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEsntlAtchFileMng(esntlAtchFileId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...esntlAtchFileMngBaseKey, esntlAtchFileId],
|
||||
queryFn: () => esntlAtchFileMngService.findById(esntlAtchFileId!),
|
||||
enabled: Boolean(esntlAtchFileId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateEsntlAtchFileMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: EsntlAtchFileMngSaveRequest) => esntlAtchFileMngService.create(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateEsntlAtchFileMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
esntlAtchFileId,
|
||||
body,
|
||||
}: {
|
||||
esntlAtchFileId: string;
|
||||
body: EsntlAtchFileMngSaveRequest;
|
||||
}) => esntlAtchFileMngService.update(esntlAtchFileId, body),
|
||||
onSuccess: (_data, { esntlAtchFileId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...esntlAtchFileMngBaseKey, esntlAtchFileId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteEsntlAtchFileMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (esntlAtchFileId: string) => esntlAtchFileMngService.remove(esntlAtchFileId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
export { esntlAtchFileMngService } from "./api/esntl-service";
|
||||
export {
|
||||
useCreateEsntlAtchFileMng,
|
||||
useDeleteEsntlAtchFileMng,
|
||||
useEsntlAtchFileMng,
|
||||
useEsntlAtchFileMngPage,
|
||||
useEsntlAtchFileMngTree,
|
||||
useUpdateEsntlAtchFileMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
EsntlAtchFileMng,
|
||||
EsntlAtchFileMngSaveRequest,
|
||||
EsntlAtchFileMngSearchParams,
|
||||
EsntlAtchFileMngService,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
EsntlAtchFileMngTreeSearchParams,
|
||||
} from "./model/esntl-interface";
|
||||
export { esntlAtchFileMngService } from "./api/esntl-service";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
/** 필수첨부파일관리 트리 조회 응답 DTO */
|
||||
export interface EsntlAtchFileMngTreeResponseDto {
|
||||
/** 필수첨부파일아이디 */
|
||||
@@ -12,14 +14,72 @@ export interface EsntlAtchFileMngTreeResponseDto {
|
||||
esntlAtchFileRmrk: string;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 조회 파라미터 */
|
||||
export interface EsntlAtchFileMngSearchParams {
|
||||
/** 필수첨부파일관리 엔티티 (목록/상세 공용 응답 DTO) */
|
||||
export interface EsntlAtchFileMng {
|
||||
/** 필수첨부파일아이디 */
|
||||
esntlAtchFileId: string;
|
||||
/** 문서구분공통코드 */
|
||||
docSeComCd: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn: "Y" | "N";
|
||||
/** 필수첨부파일비고 */
|
||||
esntlAtchFileRmrk?: string;
|
||||
/** 등록자UUID */
|
||||
rgtr?: string;
|
||||
/** 등록자명 */
|
||||
rgtrNm?: string;
|
||||
/** 수정자UUID */
|
||||
mdfr?: string;
|
||||
/** 등록일시 (yyyy-MM-dd HH:mm:ss) */
|
||||
regDt?: string;
|
||||
/** 수정일시 */
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 트리 조회 파라미터 */
|
||||
export interface EsntlAtchFileMngTreeSearchParams {
|
||||
/** 문서구분공통코드 (예: "INSTL") */
|
||||
docSeComCd: string;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 목록 조회 파라미터 (페이징 포함) */
|
||||
export interface EsntlAtchFileMngSearchParams extends PageableParams {
|
||||
/** 문서구분공통코드 (예: "INSTL") */
|
||||
docSeComCd?: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm?: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 서비스 인터페이스 */
|
||||
export interface EsntlAtchFileMngService {
|
||||
/** 필수첨부파일 트리 조회 */
|
||||
findTree(params: EsntlAtchFileMngSearchParams): Promise<EsntlAtchFileMngTreeResponseDto[]>;
|
||||
findTree(params: EsntlAtchFileMngTreeSearchParams): Promise<EsntlAtchFileMngTreeResponseDto[]>;
|
||||
/** 필수첨부파일관리 목록 조회 (페이징) */
|
||||
findPage(params: EsntlAtchFileMngSearchParams): Promise<PageResponse<EsntlAtchFileMng>>;
|
||||
/** 필수첨부파일관리 상세 조회 */
|
||||
findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng>;
|
||||
/** 필수첨부파일관리 등록 */
|
||||
create(body: EsntlAtchFileMngSaveRequest): Promise<string>;
|
||||
/** 필수첨부파일관리 수정 */
|
||||
update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string>;
|
||||
/** 필수첨부파일관리 삭제 */
|
||||
remove(esntlAtchFileId: string): Promise<string>;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 저장 요청 DTO */
|
||||
export interface EsntlAtchFileMngSaveRequest {
|
||||
/** 필수첨부파일아이디 */
|
||||
esntlAtchFileId: string;
|
||||
/** 문서구분공통코드 */
|
||||
docSeComCd?: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm?: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn?: "Y" | "N";
|
||||
/** 필수첨부파일비고 */
|
||||
esntlAtchFileRmrk?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { EsntlAtchFileMngPage } from "./ui/esntl-atch-file-mng-page";
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import type { Code } from "@/entities/code";
|
||||
import { findCodeLabel } from "@/entities/code";
|
||||
import type { EsntlAtchFileMng } from "@/entities/esntl";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<EsntlAtchFileMng>();
|
||||
|
||||
interface CreateEsntlAtchFileColumnsOptions {
|
||||
docSeCodes: Code[] | undefined;
|
||||
onEdit: (row: EsntlAtchFileMng) => void;
|
||||
onDelete: (row: EsntlAtchFileMng) => void;
|
||||
}
|
||||
|
||||
function formatYn(value: string | undefined) {
|
||||
if (value === "Y") return "예";
|
||||
if (value === "N") return "아니오";
|
||||
return "—";
|
||||
}
|
||||
|
||||
function formatOptional(value: string | undefined) {
|
||||
return value?.trim() ? value : "—";
|
||||
}
|
||||
|
||||
export function createEsntlAtchFileColumns({
|
||||
docSeCodes,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CreateEsntlAtchFileColumnsOptions) {
|
||||
return [
|
||||
columnHelper.accessor("esntlAtchFileId", {
|
||||
header: "필수첨부파일아이디",
|
||||
meta: { align: "center" },
|
||||
}),
|
||||
columnHelper.accessor("docSeComCd", {
|
||||
header: "문서구분",
|
||||
cell: ({ getValue }) => findCodeLabel(docSeCodes, getValue()) || getValue() || "—",
|
||||
}),
|
||||
columnHelper.accessor("esntlAtchFileNm", {
|
||||
header: "필수첨부파일명",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("esntlAtchYn", {
|
||||
header: "필수첨부여부",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatYn(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("esntlAtchFileRmrk", {
|
||||
header: "비고",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("regDt", {
|
||||
header: "등록일시",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("mdfcnDt", {
|
||||
header: "수정일시",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
header: "관리",
|
||||
cell: ({ row }) => (
|
||||
<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" color="red" onClick={() => onDelete(row.original)}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { EsntlAtchFileMngSearchParams } from "@/entities/esntl";
|
||||
|
||||
export const ESNTL_ATCH_FILE_ALL_DOC_SE = "";
|
||||
|
||||
export const DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE = 100;
|
||||
|
||||
export const DEFAULT_ESNTL_ATCH_FILE_SORT = "regDt,desc";
|
||||
|
||||
export function createDefaultEsntlAtchFileSearchParams(): EsntlAtchFileMngSearchParams {
|
||||
return {
|
||||
page: 0,
|
||||
size: DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
sort: DEFAULT_ESNTL_ATCH_FILE_SORT,
|
||||
};
|
||||
}
|
||||
|
||||
export function toEsntlAtchFileQueryParams(
|
||||
params: EsntlAtchFileMngSearchParams,
|
||||
): EsntlAtchFileMngSearchParams {
|
||||
const { docSeComCd, esntlAtchFileNm, esntlAtchYn, page, size, sort } = params;
|
||||
|
||||
return {
|
||||
...(docSeComCd ? { docSeComCd } : {}),
|
||||
...(esntlAtchFileNm ? { esntlAtchFileNm } : {}),
|
||||
...(esntlAtchYn ? { esntlAtchYn } : {}),
|
||||
page: page ?? 0,
|
||||
size: size ?? DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
sort: sort ?? DEFAULT_ESNTL_ATCH_FILE_SORT,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
const EAF_ID_PREFIX = "EAF-";
|
||||
const EAF_ID_PATTERN = /^EAF-(\d+)$/;
|
||||
|
||||
export function generateEsntlAtchFileId(existingIds: string[]): string {
|
||||
const maxSeq = existingIds.reduce((max, id) => {
|
||||
const match = id.match(EAF_ID_PATTERN);
|
||||
if (!match) return max;
|
||||
return Math.max(max, Number(match[1]));
|
||||
}, 0);
|
||||
|
||||
return `${EAF_ID_PREFIX}${String(maxSeq + 1).padStart(4, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import type { EsntlAtchFileMng } from "@/entities/esntl";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
export interface EsntlAtchFileFormValues {
|
||||
esntlAtchFileId: string;
|
||||
docSeComCd: string;
|
||||
esntlAtchFileNm: string;
|
||||
esntlAtchYn: "Y" | "N";
|
||||
esntlAtchFileRmrk: string;
|
||||
}
|
||||
|
||||
interface EsntlAtchFileFormDialogProps {
|
||||
mode: "create" | "edit";
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initial?: EsntlAtchFileMng | null;
|
||||
generatedId?: string;
|
||||
isSubmitting?: boolean;
|
||||
onSubmit: (values: EsntlAtchFileFormValues) => void;
|
||||
}
|
||||
|
||||
function EsntlAtchFileFormDialog({
|
||||
mode,
|
||||
open,
|
||||
onOpenChange,
|
||||
initial,
|
||||
generatedId,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}: EsntlAtchFileFormDialogProps) {
|
||||
const { register, handleSubmit, reset, setValue, watch } = useForm<EsntlAtchFileFormValues>({
|
||||
defaultValues: {
|
||||
esntlAtchFileId: "",
|
||||
docSeComCd: "",
|
||||
esntlAtchFileNm: "",
|
||||
esntlAtchYn: "Y",
|
||||
esntlAtchFileRmrk: "",
|
||||
},
|
||||
});
|
||||
|
||||
const docSeComCd = watch("docSeComCd");
|
||||
const esntlAtchYn = watch("esntlAtchYn");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (mode === "edit" && initial) {
|
||||
reset({
|
||||
esntlAtchFileId: initial.esntlAtchFileId,
|
||||
docSeComCd: initial.docSeComCd ?? "",
|
||||
esntlAtchFileNm: initial.esntlAtchFileNm ?? "",
|
||||
esntlAtchYn: initial.esntlAtchYn ?? "Y",
|
||||
esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reset({
|
||||
esntlAtchFileId: generatedId ?? "",
|
||||
docSeComCd: "",
|
||||
esntlAtchFileNm: "",
|
||||
esntlAtchYn: "Y",
|
||||
esntlAtchFileRmrk: "",
|
||||
});
|
||||
}, [open, mode, initial, generatedId, reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[52rem]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "create" ? "필수첨부파일 등록" : "필수첨부파일 수정"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
{mode === "edit" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="esntlAtchFileId">필수첨부파일아이디</Label>
|
||||
<p className="p-2 text-xl">{watch("esntlAtchFileId")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<input type="hidden" {...register("esntlAtchFileId", { required: true })} />
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>문서구분</Label>
|
||||
<CodeSelect
|
||||
upCd="ASDOCSECD"
|
||||
value={docSeComCd}
|
||||
onValueChange={(v) => setValue("docSeComCd", v, { shouldValidate: true })}
|
||||
placeholder="문서구분 선택"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="esntlAtchFileNm">필수첨부파일명</Label>
|
||||
<Input
|
||||
id="esntlAtchFileNm"
|
||||
placeholder="예: 사업계획서"
|
||||
{...register("esntlAtchFileNm", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>필수첨부여부</Label>
|
||||
<RadioGroup
|
||||
value={esntlAtchYn}
|
||||
onValueChange={(v) =>
|
||||
setValue("esntlAtchYn", v as "Y" | "N", { shouldValidate: true })
|
||||
}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id="esntlAtchYn-y" />
|
||||
<Label htmlFor="esntlAtchYn-y">예</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id="esntlAtchYn-n" />
|
||||
<Label htmlFor="esntlAtchYn-n">아니오</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="esntlAtchFileRmrk">비고</Label>
|
||||
<Textarea
|
||||
id="esntlAtchFileRmrk"
|
||||
rows={3}
|
||||
placeholder="비고를 입력하세요"
|
||||
{...register("esntlAtchFileRmrk")}
|
||||
className="!text-xl"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { EsntlAtchFileFormDialog };
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import {
|
||||
type EsntlAtchFileMng,
|
||||
type EsntlAtchFileMngSearchParams,
|
||||
useEsntlAtchFileMngPage,
|
||||
} from "@/entities/esntl";
|
||||
|
||||
import { createEsntlAtchFileColumns } from "../model/esntl-atch-file-columns";
|
||||
import { toEsntlAtchFileQueryParams } from "../model/esntl-atch-file-search-params";
|
||||
|
||||
interface EsntlAtchFileListProps {
|
||||
searchParams: EsntlAtchFileMngSearchParams;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: (pagination: PaginationState) => void;
|
||||
onEdit: (row: EsntlAtchFileMng) => void;
|
||||
onDelete: (row: EsntlAtchFileMng) => void;
|
||||
}
|
||||
|
||||
function EsntlAtchFileList({
|
||||
searchParams,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: EsntlAtchFileListProps) {
|
||||
const { data: docSeCodes } = useCodeList("ASDOCSECD");
|
||||
|
||||
const queryParams = useMemo(
|
||||
() =>
|
||||
toEsntlAtchFileQueryParams({
|
||||
...searchParams,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, searchParams],
|
||||
);
|
||||
|
||||
const pageQuery = useEsntlAtchFileMngPage(queryParams);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createEsntlAtchFileColumns({
|
||||
docSeCodes,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}),
|
||||
[docSeCodes, onDelete, onEdit],
|
||||
);
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: pageQuery,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: { pagination },
|
||||
onPaginationChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
onPaginationChange(next);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 bg-background pt-0">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-semibold">필수첨부파일 목록</h3>
|
||||
</div>
|
||||
{tableComponent}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { EsntlAtchFileList };
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import {
|
||||
type EsntlAtchFileMng,
|
||||
type EsntlAtchFileMngSearchParams,
|
||||
esntlAtchFileMngService,
|
||||
useCreateEsntlAtchFileMng,
|
||||
useDeleteEsntlAtchFileMng,
|
||||
useUpdateEsntlAtchFileMng,
|
||||
} from "@/entities/esntl";
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
|
||||
import {
|
||||
createDefaultEsntlAtchFileSearchParams,
|
||||
DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
ESNTL_ATCH_FILE_ALL_DOC_SE,
|
||||
} from "../model/esntl-atch-file-search-params";
|
||||
import { generateEsntlAtchFileId } from "../model/generate-esntl-atch-file-id";
|
||||
|
||||
import {
|
||||
EsntlAtchFileFormDialog,
|
||||
type EsntlAtchFileFormValues,
|
||||
} from "./esntl-atch-file-form-dialog";
|
||||
import { EsntlAtchFileList } from "./esntl-atch-file-list";
|
||||
|
||||
const ALL_DOC_SE_VALUE = "__ALL__";
|
||||
|
||||
function EsntlAtchFileMngPage() {
|
||||
const { confirm } = useConfirm();
|
||||
const { data: docSeCodes, isPending: isDocSeLoading } = useCodeList("ASDOCSECD");
|
||||
|
||||
const [draftDocSeComCd, setDraftDocSeComCd] = useState(ESNTL_ATCH_FILE_ALL_DOC_SE);
|
||||
const [searchParams, setSearchParams] = useState<EsntlAtchFileMngSearchParams>(
|
||||
createDefaultEsntlAtchFileSearchParams(),
|
||||
);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
});
|
||||
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
|
||||
const [editingRow, setEditingRow] = useState<EsntlAtchFileMng | null>(null);
|
||||
const [generatedId, setGeneratedId] = useState("");
|
||||
|
||||
const createMutation = useCreateEsntlAtchFileMng();
|
||||
const updateMutation = useUpdateEsntlAtchFileMng();
|
||||
const deleteMutation = useDeleteEsntlAtchFileMng();
|
||||
|
||||
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
setSearchParams({
|
||||
...createDefaultEsntlAtchFileSearchParams(),
|
||||
...(draftDocSeComCd ? { docSeComCd: draftDocSeComCd } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenCreate = async () => {
|
||||
const page = await esntlAtchFileMngService.findPage({
|
||||
page: 0,
|
||||
size: DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
sort: "esntlAtchFileId,desc",
|
||||
});
|
||||
const existingIds = page.content.map((row) => row.esntlAtchFileId);
|
||||
setGeneratedId(generateEsntlAtchFileId(existingIds));
|
||||
setEditingRow(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
|
||||
const handleEdit = useCallback((row: EsntlAtchFileMng) => {
|
||||
setEditingRow(row);
|
||||
setDialogMode("edit");
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (row: EsntlAtchFileMng) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
await deleteMutation.mutateAsync(row.esntlAtchFileId);
|
||||
},
|
||||
[confirm, deleteMutation],
|
||||
);
|
||||
|
||||
const handleFormSubmit = async (values: EsntlAtchFileFormValues) => {
|
||||
if (!values.docSeComCd || !values.esntlAtchFileNm || !values.esntlAtchYn) return;
|
||||
|
||||
const body = {
|
||||
esntlAtchFileId: values.esntlAtchFileId,
|
||||
docSeComCd: values.docSeComCd,
|
||||
esntlAtchFileNm: values.esntlAtchFileNm,
|
||||
esntlAtchYn: values.esntlAtchYn,
|
||||
esntlAtchFileRmrk: values.esntlAtchFileRmrk || undefined,
|
||||
};
|
||||
|
||||
if (dialogMode === "create") {
|
||||
if (!(await confirm("등록하시겠습니까?"))) return;
|
||||
await createMutation.mutateAsync(body);
|
||||
alert("저장되었습니다.");
|
||||
} else if (dialogMode === "edit" && editingRow) {
|
||||
await updateMutation.mutateAsync({
|
||||
esntlAtchFileId: editingRow.esntlAtchFileId,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
setDialogMode(null);
|
||||
setEditingRow(null);
|
||||
setGeneratedId("");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode(null);
|
||||
setEditingRow(null);
|
||||
setGeneratedId("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background">
|
||||
<form
|
||||
className="flex flex-wrap items-end justify-between gap-4 p-4"
|
||||
onSubmit={handleSearch}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Label htmlFor="docSeComCd">문서구분</Label>
|
||||
<Select
|
||||
value={draftDocSeComCd || ALL_DOC_SE_VALUE}
|
||||
onValueChange={(value) =>
|
||||
setDraftDocSeComCd(value === ALL_DOC_SE_VALUE ? ESNTL_ATCH_FILE_ALL_DOC_SE : value)
|
||||
}
|
||||
disabled={isDocSeLoading}
|
||||
>
|
||||
<SelectTrigger id="docSeComCd" className="w-[20rem]">
|
||||
<SelectValue placeholder={isDocSeLoading ? "로딩 중…" : "전체"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_DOC_SE_VALUE}>전체</SelectItem>
|
||||
{docSeCodes?.map((item) => (
|
||||
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
|
||||
{item.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="submit">조회</Button>
|
||||
</div>
|
||||
<Button type="button" onClick={handleOpenCreate}>
|
||||
등록
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<EsntlAtchFileList
|
||||
searchParams={searchParams}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{dialogMode ? (
|
||||
<EsntlAtchFileFormDialog
|
||||
mode={dialogMode}
|
||||
open={Boolean(dialogMode)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeDialog();
|
||||
}}
|
||||
initial={dialogMode === "edit" ? editingRow : null}
|
||||
generatedId={generatedId}
|
||||
isSubmitting={createMutation.isPending || updateMutation.isPending}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { EsntlAtchFileMngPage };
|
||||
@@ -15,10 +15,7 @@ import type { CklstTreeNode } from "@/entities/cklst";
|
||||
import { cklstMngService } from "@/entities/cklst";
|
||||
import type { CnstpSeMngTreeDto } from "@/entities/cnstp";
|
||||
import { cnstpService } from "@/entities/cnstp";
|
||||
import {
|
||||
esntlAtchFileMngService,
|
||||
type EsntlAtchFileMngTreeResponseDto,
|
||||
} from "@/entities/esntl";
|
||||
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
|
||||
import {
|
||||
INSTL_SNCN_REGISTER_SELECTION_KEY,
|
||||
InstlSncnApi,
|
||||
@@ -140,14 +137,13 @@ function CnfmRegistPage() {
|
||||
const etcFileGroupIdRef = useRef("");
|
||||
|
||||
/* ── 필수 첨부파일 ── */
|
||||
const { data: requiredAttachmentDefs = [] } = useQuery<EsntlAtchFileMngTreeResponseDto[]>({
|
||||
queryKey: ["esntlAtchFileTree", docSeComCd],
|
||||
queryFn: () => esntlAtchFileMngService.findTree({ docSeComCd }),
|
||||
enabled: Boolean(docSeComCd),
|
||||
staleTime: Infinity,
|
||||
const { data: requiredAttachmentDefs = [] } = useEsntlAtchFileMngTree({
|
||||
docSeComCd,
|
||||
});
|
||||
|
||||
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>({});
|
||||
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>(
|
||||
{},
|
||||
);
|
||||
const requiredFileInputRefs = useRef<Record<RequiredAttachKey, HTMLInputElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -231,7 +227,10 @@ function CnfmRegistPage() {
|
||||
upload.start();
|
||||
};
|
||||
|
||||
const handleRequiredFileChange = (key: RequiredAttachKey, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleRequiredFileChange = (
|
||||
key: RequiredAttachKey,
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploadRequiredFile(file, key);
|
||||
@@ -275,7 +274,7 @@ function CnfmRegistPage() {
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
const ext = file.name.includes(".") ? file.name.split(".").pop() ?? "" : "";
|
||||
const ext = file.name.includes(".") ? (file.name.split(".").pop() ?? "") : "";
|
||||
resolve({
|
||||
id: uploadedAtflId || `${Date.now()}_${Math.random()}`,
|
||||
name: file.name,
|
||||
@@ -375,8 +374,7 @@ function CnfmRegistPage() {
|
||||
return ordered;
|
||||
}, [cklstTree]);
|
||||
|
||||
const cklstKey = (row: CklstTreeNode) =>
|
||||
`${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}`;
|
||||
const cklstKey = (row: CklstTreeNode) => `${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}`;
|
||||
|
||||
useEffect(() => {
|
||||
setCheckResults((prev) => {
|
||||
@@ -423,13 +421,12 @@ function CnfmRegistPage() {
|
||||
}, [cklstRows, checkResults]);
|
||||
|
||||
const toCklstSaveDtoList = () =>
|
||||
cklstRows
|
||||
.map((row) => ({
|
||||
cklstSn: row.cklstSn,
|
||||
cklstArtclSn: row.cklstArtclSn,
|
||||
cklstRvwSn: row.cklstRvwSn,
|
||||
selected: checkResults[cklstKey(row)] ?? null,
|
||||
}));
|
||||
cklstRows.map((row) => ({
|
||||
cklstSn: row.cklstSn,
|
||||
cklstArtclSn: row.cklstArtclSn,
|
||||
cklstRvwSn: row.cklstRvwSn,
|
||||
selected: checkResults[cklstKey(row)] ?? null,
|
||||
}));
|
||||
|
||||
const toEsntlAtchFileSaveDtoList = () =>
|
||||
Object.values(requiredFiles)
|
||||
@@ -458,7 +455,9 @@ function CnfmRegistPage() {
|
||||
|
||||
const subtotalScale = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
|
||||
cnstpTree
|
||||
.filter((n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd))
|
||||
.filter(
|
||||
(n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd),
|
||||
)
|
||||
.reduce((sum, n) => sum + num((bzScaleMap[n.cnstpSeCd] ?? emptyScale())[col]), 0);
|
||||
|
||||
const scaleNodeVal = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
|
||||
@@ -574,7 +573,6 @@ function CnfmRegistPage() {
|
||||
|
||||
/* ── 검증 ── */
|
||||
function fnValidation(mode: "SAVE" | "PRESENTN") {
|
||||
|
||||
if (!bzNm.trim()) {
|
||||
alert("사업명을 입력하세요.");
|
||||
return false;
|
||||
@@ -591,16 +589,13 @@ function CnfmRegistPage() {
|
||||
alert("개인정보 제공에 동의하셔야 제출이 가능합니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 체크리스트 사용 항목(Y) 중 미선택 항목이 있으면 진행 불가
|
||||
const hasUncheckedChecklist = cklstRows.some(
|
||||
(row) => !checkResults[cklstKey(row)],
|
||||
);
|
||||
const hasUncheckedChecklist = cklstRows.some((row) => !checkResults[cklstKey(row)]);
|
||||
if (hasUncheckedChecklist) {
|
||||
alert("체크리스트를 모두 체크하세요.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 저장 시: 필수 첨부파일(esntlAtchYn=Y) 업로드 여부 검증
|
||||
if (mode === "SAVE") {
|
||||
const missingRequiredFile = requiredAttachmentDefs.find((item) => {
|
||||
@@ -692,7 +687,7 @@ function CnfmRegistPage() {
|
||||
function handleSubmit() {
|
||||
if (!fnValidation("PRESENTN")) return;
|
||||
const payload = {
|
||||
instlSncnMng: toInstlSncnMngPayload("ST"),
|
||||
instlSncnMng: toInstlSncnMngPayload("ST"),
|
||||
bzScaleList: toBzScaleSaveDtoList(),
|
||||
bizCstList: toBizCstSaveDtoList(),
|
||||
cklstRvwRsltList: toCklstSaveDtoList(),
|
||||
@@ -1107,7 +1102,8 @@ function CnfmRegistPage() {
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
|
||||
<button className="cnfm-btn-search text-[1.2rem]">
|
||||
{(cklstRows[0]?.cklstNm ?? taskSeComCdNm) + `(${cklstStats.completed}/${cklstStats.total})`}
|
||||
{(cklstRows[0]?.cklstNm ?? taskSeComCdNm) +
|
||||
`(${cklstStats.completed}/${cklstStats.total})`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 text-[1.25rem] text-gray-500">
|
||||
@@ -1173,7 +1169,10 @@ function CnfmRegistPage() {
|
||||
}}
|
||||
>
|
||||
<td className="text-center text-[1.2rem]">{row.cklstArtclNm}</td>
|
||||
<td className="!text-left pl-2 text-[1.2rem] text-gray-600" style={{ paddingLeft: rvwPaddingLeft }}>
|
||||
<td
|
||||
className="pl-2 !text-left text-[1.2rem] text-gray-600"
|
||||
style={{ paddingLeft: rvwPaddingLeft }}
|
||||
>
|
||||
{row.treeLevel > 1 ? "└ " : ""}
|
||||
{row.rvwCn}
|
||||
</td>
|
||||
@@ -1238,7 +1237,9 @@ function CnfmRegistPage() {
|
||||
<td className="pl-2">
|
||||
<div className="w-full text-left">
|
||||
{item.esntlAtchFileNm}
|
||||
{item.esntlAtchYn === "Y" ? <span className="ml-1 text-red-500">*</span> : null}
|
||||
{item.esntlAtchYn === "Y" ? (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{fileFormat}</td>
|
||||
@@ -1248,7 +1249,11 @@ function CnfmRegistPage() {
|
||||
type="text"
|
||||
placeholder="첨부문서를 올려주세요"
|
||||
className="cnfm-input flex-1"
|
||||
value={requiredFile?.uploading ? "업로드 중..." : requiredFile?.fileName || ""}
|
||||
value={
|
||||
requiredFile?.uploading
|
||||
? "업로드 중..."
|
||||
: requiredFile?.fileName || ""
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user