diff --git a/apps/cs/app/(with-base-layout)/link/page.tsx b/apps/cs/app/(with-base-layout)/link/page.tsx new file mode 100644 index 000000000..c703d7fcf --- /dev/null +++ b/apps/cs/app/(with-base-layout)/link/page.tsx @@ -0,0 +1,2 @@ +import { LinkPage } from "@/pages/link"; +export default LinkPage; \ No newline at end of file diff --git a/apps/cs/app/(with-base-layout)/link/register/[id]/page.tsx b/apps/cs/app/(with-base-layout)/link/register/[id]/page.tsx new file mode 100644 index 000000000..b54d60cec --- /dev/null +++ b/apps/cs/app/(with-base-layout)/link/register/[id]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyLinkPage as VModifyLinkPage } from "@/pages/link"; + +export default async function ModifyLinkPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +}; \ No newline at end of file diff --git a/apps/cs/app/(with-base-layout)/link/register/page.tsx b/apps/cs/app/(with-base-layout)/link/register/page.tsx new file mode 100644 index 000000000..a7be897af --- /dev/null +++ b/apps/cs/app/(with-base-layout)/link/register/page.tsx @@ -0,0 +1,2 @@ +import { RegistLinkPage } from "@/pages/link"; +export default RegistLinkPage; \ No newline at end of file diff --git a/apps/cs/app/(with-base-layout)/linkLog/page.tsx b/apps/cs/app/(with-base-layout)/linkLog/page.tsx new file mode 100644 index 000000000..bf10b7ba0 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/linkLog/page.tsx @@ -0,0 +1,2 @@ +import { LinkLogPage } from "@/pages/linkLog"; +export default LinkLogPage; \ No newline at end of file diff --git a/apps/cs/app/(with-base-layout)/linkParams/page.tsx b/apps/cs/app/(with-base-layout)/linkParams/page.tsx new file mode 100644 index 000000000..a9e250bac --- /dev/null +++ b/apps/cs/app/(with-base-layout)/linkParams/page.tsx @@ -0,0 +1,2 @@ +import { LinkParamsPage } from "@/pages/linkParams"; +export default LinkParamsPage; \ No newline at end of file diff --git a/apps/cs/app/(with-base-layout)/linkStats/page.tsx b/apps/cs/app/(with-base-layout)/linkStats/page.tsx new file mode 100644 index 000000000..78acc9fc3 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/linkStats/page.tsx @@ -0,0 +1,2 @@ +import { LinkStatsPage } from "@/pages/linkStats"; +export default LinkStatsPage; \ No newline at end of file diff --git a/apps/cs/app/(with-base-layout)/linkTable/page.tsx b/apps/cs/app/(with-base-layout)/linkTable/page.tsx new file mode 100644 index 000000000..df14e95e3 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/linkTable/page.tsx @@ -0,0 +1,2 @@ +import { LinkTablePage } from "@/pages/linkTable"; +export default LinkTablePage; \ No newline at end of file diff --git a/apps/cs/src/entities/link/api/link-service.ts b/apps/cs/src/entities/link/api/link-service.ts new file mode 100644 index 000000000..c607dd2c7 --- /dev/null +++ b/apps/cs/src/entities/link/api/link-service.ts @@ -0,0 +1,191 @@ +import axios from "axios"; +import { + type LinkService, + type LinkSearchParam, + type LinkList, + type LinkFormData, + type LinkInfo, + type LinkLogList, + type LinkStatsList, + type LinkVrblList, + type LinkVrblFormData, + type LinkVrbl, + type LinkTable, + type LinkTableColumnsList, + type LinkTableColumns, + type LinkTableColData, + type LinkTableFormData, +} from "../model/link-interface"; + +export class LinkApiService implements LinkService { + public async getLinkList(params: LinkSearchParam): Promise { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, + { params } + ); + return response.data; + } + + public async getLinkInfo(linkSn: string): Promise { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkInfo/${linkSn}`, + ); + return response.data; + } + + public async linkQuickStart(linkSn: string): Promise { + const response = await axios.patch( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/linkQuickStart/${linkSn}`, + ); + return response.data; + } + + public async updateLinkInfo(data: LinkFormData): Promise { + await axios.put( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/updateLinkInfo`, + data, + ); + } + + public async insertLinkInfo(data: LinkFormData): Promise { + await axios.post( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/insertLinkInfo`, + data, + ); + } + + public async deleteLink(linkSn: string): Promise { + await axios.delete( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/deleteLink/${linkSn}`, + ); + } + + public async getLinkLogList(params: LinkSearchParam): Promise { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/selectLinkLoglist`, + { params } + ); + return response.data; + } + + public async getLinkStatsList(params: LinkSearchParam): Promise { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/selectLinkStats`, + { params } + ); + return response.data; + } + + public async getLinkVrblList(linkSn: string): Promise { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkVrblList/${linkSn}`, + ); + return response.data; + } + + public async insertLinkVrbl(data: LinkVrblFormData): Promise { + const response = await axios.post( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/insertLinkVrbl`, + data, + ); + + return response.data; + } + + public async deleteLinkVrbl(data: string[]): Promise { + await axios.delete( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/deleteLinkVrbl`, + { + data + } + ); + } + + public async getLinkVrblInfo(linkVrblSn: string): Promise { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkVrblInfo/${linkVrblSn}`, + ); + return response.data; + } + + public async updateLinkVrblInfo(data: LinkVrblFormData): Promise { + await axios.put( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/updateLinkVrblInfo`, + data, + ); + } + + public async getLinkTableInfo(linkSn: string): Promise { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo/${linkSn}`, + ); + return response.data; + } + + public async getLinkTableColList(linkSn: string): Promise { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList/${linkSn}`, + ); + return response.data; + } + + public async getLinkTableColInfo(params: { colSn: string; tblSn:string; }): Promise { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColInfo`, + { params } + ); + return response.data; + } + + public async insertLinkTableCol(data: LinkTableColData): Promise { + await axios.post( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/insertLinkTableCol`, + data + ); + } + + public async deleteLinkTableCol(data: string[]): Promise { + await axios.delete( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/deleteLinkTableCol`, + { + data + }, + ); + } + + public async updateLinkTableCol(data: LinkTableColData): Promise { + axios.put( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/updateLinkTableCol`, + data, + ); + } + + public async insertLinkTableInfo(data: LinkTableFormData): Promise { + await axios.post( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/insertLinkTableInfo`, + data + ); + } + + public async updateLinkTableInfo(data: LinkTableFormData): Promise { + await axios.put( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/updateLinkTableInfo`, + data + ); + } + + public async countLinkTblNm(tblNm: string): Promise<{count: number, tbl_sn: string}> { + const response = await axios.get<{count: number, tbl_sn: string}>( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/countLinkTblNm/${tblNm}`, + ); + return response.data; + } + + public async updateLinkResultCode(linkSn: string): Promise { + await axios.patch( + `${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/updateLinkResultCode/${linkSn}`, + ); + } +} + +export const LinkApi = new LinkApiService(); \ No newline at end of file diff --git a/apps/cs/src/entities/link/index.ts b/apps/cs/src/entities/link/index.ts new file mode 100644 index 000000000..89d56aaa0 --- /dev/null +++ b/apps/cs/src/entities/link/index.ts @@ -0,0 +1,18 @@ +export { LinkApi } from "./api/link-service"; +export { + type LinkSearchParam, + type Link, + type LinkFormData, + type LinkLog, + type LinkStats, + type LinkVrbl, + type LinkVrblFormData, + type LinkTable, + type LinkTableColumns, + type LinkTableColData, + type LinkTableFormData, + linkFormSchema, + linkVrblFormSchema, + LinkTableColumnsSchema, + LinkTableSchema, +} from "./model/link-interface" \ No newline at end of file diff --git a/apps/cs/src/entities/link/model/link-interface.ts b/apps/cs/src/entities/link/model/link-interface.ts new file mode 100644 index 000000000..c4493a6c0 --- /dev/null +++ b/apps/cs/src/entities/link/model/link-interface.ts @@ -0,0 +1,235 @@ +import { z } from "zod"; +import type { PageableParams, PageableList } from "@/shared/model"; + +export interface Link { + link_sn: string; + link_nm: string; + link_clsf_cd: string; + link_url: string; + link_srvr_ip: string; + link_db_cd: string; + link_key: string; + link_id: string; + link_pswd: string; + link_cycle_stng: string; + srvc_usg: string; + last_link_rslt_cd: string; + last_link_excn_dt: string; + next_link_excn_dt: string; + last_link_err_cd: string; + mdfcn_dt: string; + mdfr: string; + link_batch_time: string; +} + +export interface LinkLog { + rownum: number; + link_sn: string; + link_nm: string; + link_rslt_cd: string; + err_cn: string; + link_err_cd: string; + log_crt_dt: string; +} + +export interface LinkInfo extends LinkFormData { + link_sn: string; + link_nm: string; + link_clsf_cd: string; + link_url: string; + link_srvr_ip: string; + link_db_cd: string; + link_key: string; + link_id: string; + link_pswd: string; + srvc_usg: string; + last_link_rslt_cd: string; + last_link_excn_dt: string; + next_link_excn_dt: string; + last_link_err_cd: string; + mdfcn_dt: string; + mdfr: string; + link_batch_time: number; +} + +export interface LinkStats { + link_sn: string; + link_nm: string; + count: number; +} + +export interface LinkVrbl { + link_vrbl_sn: string; + link_vrbl_nm: string; + link_vrbl_vl: string; + link_vrbl_cd: string; + link_vrbl_expln: string; + mdfcn_dt: string; + mdfr: string; +} + +export interface LinkTable { + link_nm: string; + link_sn: string; + tbl_sn: string; + sou_tbl_nm: string; + sou_tbl_korn_nm: string; + sou_tbl_expln: string; + trsf_tbl_nm: string; + trsf_tbl_korn_nm: string; + trsf_tbl_expln: string; +} + +export interface LinkTableColumns { + tbl_sn: string; + col_sn: string; + sou_col_nm: string; + sou_col_korn_nm: string; + sou_col_data_frm: string; + sou_col_sz: number; + trsf_col_nm: string; + trsf_col_korn_nm: string; + trsf_col_data_frm: string; + trsf_col_sz: number + pk_yn: string; + col_seq: number; + mdfcn_dt: string; + mdfr: string; +} + +export const linkFormSchema = z.object({ + link_clsf_cd: z.string().max(8).nullable().refine((val) => { + // 빈 값이나 "none" 값이면 false 반환 + return val && val !== "none"; + }, { + message: "연계 분류를 선택해주세요" + }), + link_sn: z.string().max(50).nullable().optional(), + link_nm: z.string().max(40, { message: '연계명은 40자 이하로 입력해 주세요.' }).nonempty({ message: '연계명은 필수입니다.' }), + link_url: z.string().max(1000, { message: '연계 URL은 1000자 이하로 입력해 주세요.' }).nonempty({ message: '연계 URL은 필수입니다.' }), + link_srvr_ip: z.string().max(20).nullable().optional(), + link_db_cd: z.string().max(10).nullable().optional(), + link_key: z.string().max(100, { message: '연계 KEY는 100자 이하로 입력해 주세요.' }).nonempty({ message: '연계 KEY는 필수입니다.' }), + link_id: z.string().max(20).nullable().optional(), + link_pswd: z.string().max(128).nullable().optional(), + srvc_usg: z.string().max(40).nullable().optional(), + last_link_rslt_cd: z.string().max(4).nullable().optional(), + last_link_excn_dt: z.string().max(40).nullable().optional(), + next_link_excn_dt: z.string().max(40).nullable().optional(), + last_link_err_cd: z.string().max(10).nullable().optional(), + mdfcn_dt: z.string().nullable().optional(), + mdfr: z.string().nullable().optional(), + link_batch_time: z.coerce.number().nullable().optional().refine((val) => { + if (val == null) return true; + return val >= 5; + }, { + message: "연계 주기는 5분 이상이어야 합니다." + }), +}); + +export const linkVrblFormSchema = z.object({ + link_sn: z.string().max(50).nullable().optional(), + link_vrbl_sn: z.string().max(50).nullable().optional(), + link_vrbl_nm: z.string().max(40, { message: '변수명은 40자 이하로 입력해 주세요.' }).nonempty({ message: '변수명은 필수입니다.' }), + link_vrbl_expln: z.string().max(4000, { message: '설명은 400자 이하로 입력해 주세요.' }).nullable().optional(), + link_vrbl_cd: z.string().max(10, { message: '변수코드는 10자 이하로 입력해 주세요.' }).nullable().optional(), + link_vrbl_vl: z.string().max(1000, { message: '변수값은 1000자 이하로 입력해 주세요.' }).nonempty({ message: '변수값은 필수입니다.' }), + mdfcn_dt: z.string().nullable().optional(), + mdfr: z.string().nullable().optional(), +}); + +export const LinkTableColumnsSchema = z.object({ + link_sn: z.string().max(50).nullable().optional(), + tbl_sn: z.string().max(50).nullable().optional(), + col_sn: z.string().max(50).nullable().optional(), + sou_col_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nullable().optional(), + sou_col_korn_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nullable().optional(), + sou_col_data_frm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nullable().optional(), + sou_col_sz: z.coerce.number().nullable().optional().refine((val) => { + if (val == null) return true; + return val.toString().replace('.', '').length <= 14; + }, { + message: '최대 14자리까지 입력 가능합니다.', + }), + pk_yn: z.string().max(1).nonempty(), + trsf_col_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nonempty({ message: '변환컬럼명은 필수입니다.' }), + trsf_col_korn_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nonempty({ message: '변환컬럼 한글명은 필수입니다.' }), + trsf_col_data_frm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nonempty({ message: '변환컬럼 데이터 형식은 필수입니다.' }), + trsf_col_sz: z.coerce.number().nullable().optional().refine((val) => { + if (val == null) return true; + return val.toString().replace('.', '').length <= 14; + }, { + message: '최대 14자리까지 입력 가능합니다.', + }), + col_seq: z.coerce.number().nullable().optional().refine((val) => { + if (val == null) return true; + return val > 0; + }, { + message: "컬럼 매칭 순서는 0 이상이어야 합니다." + }), + mdfcn_dt: z.string().nullable().optional(), + mdfr: z.string().nullable().optional(), +}); + +export const LinkTableSchema = z.object({ + link_sn: z.string().max(50).nullable().optional(), + tbl_sn: z.string().max(50).nullable().optional(), + link_nm: z.string().max(50).nullable().optional(), + sou_tbl_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nullable().optional(), + sou_tbl_korn_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nullable().optional(), + sou_tbl_expln: z.string().max(2000, { message: '설명은 2000자 이하로 입력해 주세요.' }).nullable().optional(), + trsf_tbl_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nonempty({ message: '변환테이블명은 필수입니다.' }), + trsf_tbl_korn_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nonempty({ message: '변환테이블 한글명은 필수입니다.' }), + trsf_tbl_expln: z.string().max(2000, { message: '설명은 2000자 이하로 입력해 주세요.' }).nullable().optional(), + mdfcn_dt: z.string().nullable().optional(), + mdfr: z.string().nullable().optional(), +}); + +interface LinkStatsListObject { + linkSuccStats: LinkStats[] | null; + linkFailStats: LinkStats[] | null; +} + +export interface LinkSearchParam extends PageableParams { + linkSn?: string; + linkNm?: string; + startDate?: string; + endDate?: string; + isSucc?: string; +} + +export type LinkFormData = z.infer; +export type LinkVrblFormData = z.infer; +export type LinkTableColData = z.infer; +export type LinkTableFormData = z.infer; +export type LinkList = PageableList; +export type LinkLogList = PageableList; +export type LinkStatsList = LinkStatsListObject; +export type LinkVrblList = PageableList; +export type LinkTableColumnsList = PageableList; + +export interface LinkService { + getLinkList(params: LinkSearchParam): Promise; + getLinkInfo(params: string): Promise; + linkQuickStart(params: string): Promise; + updateLinkInfo(data: LinkFormData): Promise; + deleteLink(params: string): Promise; + getLinkLogList(params: LinkSearchParam): Promise; + getLinkStatsList(params: LinkSearchParam): Promise; + getLinkVrblList(params: string): Promise; + insertLinkVrbl(data: LinkVrblFormData): Promise; + deleteLinkVrbl(data: string[]): Promise; + getLinkVrblInfo(params: string): Promise; + updateLinkVrblInfo(data: LinkVrblFormData): Promise; + getLinkTableInfo(params: string): Promise; + getLinkTableColList(params: string): Promise; + getLinkTableColInfo(params: { colSn: string; tblSn: string; }): Promise; + insertLinkTableCol(data: LinkTableColData): Promise; + deleteLinkTableCol(data: string[]): Promise; + updateLinkTableCol(data: LinkTableColData): Promise; + insertLinkTableInfo(data: LinkTableFormData): Promise; + updateLinkTableInfo(data: LinkTableFormData): Promise; + insertLinkInfo(data: LinkFormData): Promise; + countLinkTblNm(tblNm: string): Promise<{ count: number, tbl_sn: string }>; + updateLinkResultCode(linkSn: string): Promise; +} \ No newline at end of file diff --git a/apps/cs/src/pages/link/index.ts b/apps/cs/src/pages/link/index.ts new file mode 100644 index 000000000..6eac7d15c --- /dev/null +++ b/apps/cs/src/pages/link/index.ts @@ -0,0 +1,3 @@ +export { ModifyLinkPage } from "./ui/modify-page"; +export { RegistLinkPage } from "./ui/regist-page"; +export { LinkPage } from "./ui/link-page"; \ No newline at end of file diff --git a/apps/cs/src/pages/link/model/table-columns.tsx b/apps/cs/src/pages/link/model/table-columns.tsx new file mode 100644 index 000000000..8ea7e3fb6 --- /dev/null +++ b/apps/cs/src/pages/link/model/table-columns.tsx @@ -0,0 +1,114 @@ +import dayjs from "dayjs"; +import { createColumnHelper } from "@tanstack/react-table"; +import { type Link } from "@/entities/link"; +import { Button } from "@common/components"; +import { useMutation } from "@tanstack/react-query"; +import { LinkApi } from "@/entities/link"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; + +const columnHelper = createColumnHelper(); + +const convertMinutesToPeriod = (minutes: number): string => { + if (!minutes) return ''; + + const year = Math.floor(minutes / (525600)); // 365일 * 24시간 * 60분 + minutes = minutes % 525600; + + const month = Math.floor(minutes / (43200)); // 30일 * 24시간 * 60분 + minutes = minutes % 43200; + + const day = Math.floor(minutes / 1440); // 24시간 * 60분 + minutes = minutes % 1440; + + const hour = Math.floor(minutes / 60); + const minute = minutes % 60; + + const parts = []; + if (year > 0) parts.push(`${year}년`); + if (month > 0) parts.push(`${month}개월`); + if (day > 0) parts.push(`${day}일`); + if (hour > 0) parts.push(`${hour}시간`); + if (minute > 0) parts.push(`${minute}분`); + + return parts.join(' '); +}; + +const columns = [ + columnHelper.accessor((_, index) => index + 1, { + id: 'no', + header: 'NO', + }), + columnHelper.accessor('link_sn', { + id: 'link_sn', + header: '연계일련번호', + }), + columnHelper.accessor('link_nm', { + id: 'link_nm', + header: '연계 명', + }), + columnHelper.accessor('link_url', { + id: 'link_url', + header: '연계 URL', + cell: (info) => (info.getValue() ? info.getValue() : '-'), + }), + columnHelper.accessor('link_srvr_ip', { + id: 'link_srvr_ip', + header: '연계서버 IP', + cell: (info) => (info.getValue() ? info.getValue() : '-'), + }), + columnHelper.accessor('link_batch_time', { + id: 'link_batch_time', + header: '연계 주기', + cell: (info) => convertMinutesToPeriod(Number(info.getValue())), + }), + columnHelper.accessor('srvc_usg', { + id: 'srvc_usg', + header: '서비스용도', + cell: (info) => (info.getValue() ? info.getValue() : '-'), + }), + columnHelper.display({ + id: 'last_link_rslt_cd', + header: '연계상태', + cell: (info) => { + const resultCd = info.row.original.last_link_rslt_cd; + return {resultCd === 'SUCC' ? '성공' : (resultCd === 'FAIL' ? '실패' : '대기')} + }, + }), + columnHelper.accessor('last_link_excn_dt', { + id: 'last_link_excn_dt', + header: '최종연계일', + cell: (info) => (info.getValue() ? dayjs(info.getValue()).format('YYYY-MM-DD HH:mm') : '-'), + }), + columnHelper.display({ + id: 'quick_start_btn', + header: '즉시 실행', + cell: (info) => { + const row = info.row.original; + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: updateQuickStart } = useMutation({ + mutationFn: (linkSn: string) => LinkApi.linkQuickStart(linkSn), + onSuccess: (resut) => { + if (resut === 'succ') { + alert('즉시 실행 요청이 완료되었습니다.'); + } else { + alert('기존 요청이 실행 대기중입니다.'); + } + }, + onError: () => { + alert('즉시 실행 요청을 실패했습니다.'); + } + }); + + return ( + + ) + } + }) +]; + +export { columns }; \ No newline at end of file diff --git a/apps/cs/src/pages/link/ui/link-form.tsx b/apps/cs/src/pages/link/ui/link-form.tsx new file mode 100644 index 000000000..a5f09af18 --- /dev/null +++ b/apps/cs/src/pages/link/ui/link-form.tsx @@ -0,0 +1,586 @@ +import { + Button, + Input, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from "@common/components"; +import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import type { UseFormReturn } from "react-hook-form"; +import { type LinkFormData } from "@/entities/link"; +import dayjs from "dayjs"; +import { useRouter } from "next/navigation"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useState } from "react"; + + +type LinkFormProps = { + form: UseFormReturn; + linkSn?: string; + updateLinkInfo?: (data: LinkFormData) => void; + handleInsertLink?: (data: LinkFormData) => void; + deleteLink?: () => void; + updateLinkResultCode?: () => void; +} + +const LinkForm = ({ form, linkSn, updateLinkInfo, handleInsertLink, deleteLink, updateLinkResultCode }: LinkFormProps) => { + const isModifyMode = !!linkSn; + const router = useRouter(); + + const { confirm } = useConfirm(); + const [onConfirm, setOnConfirm] = useState(false); + + const handleConfirm = async (callback: () => Promise) => { + setOnConfirm(true); + await callback(); + setOnConfirm(false); + }; + + const handleSubmit = async (data: LinkFormData) => { + const confirmMsg = !!linkSn ? '수정 하시겠습니까?' : '저장 하시겠습니까?'; + + await handleConfirm(async () => { + + if (await confirm(confirmMsg)) { + !!linkSn ? updateLinkInfo?.(data) : handleInsertLink?.(data); + } + }); + }; + + const handleDelete = async () => { + await handleConfirm(async () => { + if (await confirm('삭제하시겠습니까?')) { + deleteLink?.(); + } + }) + }; + + const convertMinutesToPeriod = (minutes: number) => { + if (!minutes) return { + year: 0, + month: 0, + day: 0, + hour: 0, + minute: 0 + }; + + const year = Math.floor(minutes / (525600)); // 365일 * 24시간 * 60분 + minutes = minutes % 525600; + + const month = Math.floor(minutes / (43200)); // 30일 * 24시간 * 60분 + minutes = minutes % 43200; + + const day = Math.floor(minutes / 1440); // 24시간 * 60분 + minutes = minutes % 1440; + + const hour = Math.floor(minutes / 60); + const minute = minutes % 60; + + const timeParts = { + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + }; + + return timeParts; + }; + + return ( + <> +
+ +
+ + + + + + + {isModifyMode && + + 연계 일련번호 + + ( + + + + + + )} + /> + + + } + + * 연계 명 + + ( + + + + + {form.formState.errors.link_nm && ( +

+ {form.formState.errors.link_nm.message} +

+ )} +
+ )} + /> +
+
+ + * 연계 분류 + + ( + + + + + {form.formState.errors.link_clsf_cd && ( +

+ {form.formState.errors.link_clsf_cd.message} +

+ )} +
+ )} + /> +
+
+ + * 연계 URL + + ( + + + + + {form.formState.errors.link_url && ( +

+ {form.formState.errors.link_url.message} +

+ )} +
+ )} + /> +
+
+ + 연계서버 IP + + ( + + + + + + )} + /> + + + + * 연계 KEY + + ( + + + + + {form.formState.errors.link_key && ( +

+ {form.formState.errors.link_key.message} +

+ )} +
+ )} + /> +
+
+ + 연계 ID + + ( + + + + + + )} + /> + + + + 연계 비밀번호 + + ( + + + + + + )} + /> + + + + 서비스 용도 + + ( + + + + + + )} + /> + + + + 연계 주기 + + { + const timeParts = convertMinutesToPeriod(Number(field.value)); + return ( + + +
+
+ { + const total = (Number(e.target.value) * 525600) + + (timeParts.month * 43200) + + (timeParts.day * 1440) + + (timeParts.hour * 60) + + timeParts.minute; + field.onChange(total); + }} + /> + 년 +
+
+ { + const total = (timeParts.year * 525600) + + (Number(e.target.value) * 43200) + + (timeParts.day * 1440) + + (timeParts.hour * 60) + + timeParts.minute; + field.onChange(total); + }} + /> + 월 +
+
+ { + const total = (timeParts.year * 525600) + + (timeParts.month * 43200) + + (Number(e.target.value) * 1440) + + (timeParts.hour * 60) + + timeParts.minute; + field.onChange(total); + }} + /> + 일 +
+
+ { + const total = (timeParts.year * 525600) + + (timeParts.month * 43200) + + (timeParts.day * 1440) + + (Number(e.target.value) * 60) + + timeParts.minute; + field.onChange(total); + }} + /> + 시간 +
+
+ { + const total = (timeParts.year * 525600) + + (timeParts.month * 43200) + + (timeParts.day * 1440) + + (timeParts.hour * 60) + + Number(e.target.value); + field.onChange(total); + }} + /> + 분 +
+
+
+ {form.formState.errors.link_batch_time && ( +

+ {form.formState.errors.link_batch_time.message} +

+ )} +
+ ); + }} + /> +
+
+ {isModifyMode && + <> + + 연계 상태 + + { + return ( + + +
+ + +
+
+
+ ); + }} + /> +
+
+ + 최종 연계 실행일 + + ( + + + + + + )} + /> + + + + 다음 연계 실행일 + + ( + + + + + + )} + /> + + + + 수정자 + + ( + + + + + + )} + /> + + + + 수정일시 + + ( + + + + + + )} + /> + + + + } +
+
+
+
+ + + {isModifyMode && ( + + )} +
+
+ + + + ) +}; + +export default LinkForm; \ No newline at end of file diff --git a/apps/cs/src/pages/link/ui/link-page.tsx b/apps/cs/src/pages/link/ui/link-page.tsx new file mode 100644 index 000000000..ee5f4d93f --- /dev/null +++ b/apps/cs/src/pages/link/ui/link-page.tsx @@ -0,0 +1,134 @@ +"use client"; + +import "@common/assets/styles/grid.css"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button, DataTable, Input, Label, LoadingWithAnimation } from "@common/components"; +import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { LinkApi, type Link, type LinkSearchParam } from "@/entities/link"; +import { useQuery } from "@tanstack/react-query"; +import { columns } from "../model/table-columns"; +import { useQueryClient } from "@tanstack/react-query"; +import { useDataTable } from "@/shared/lib"; + +function LinkPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + + const [linkSn, setLinkSn] = useState(''); + const [linkNm, setLinkNm] = useState(''); + + const [linkSearchParam, setLinkSearchParam] = useState({ + linkSn: '', + linkNm: '' + }); + + const query = useQuery({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, linkSearchParam], + queryFn: () => LinkApi.getLinkList(linkSearchParam), + }); + + const handleResetSearchParam = () => { + setLinkSn(''); + setLinkNm(''); + setLinkSearchParam({ + ...linkSearchParam, + linkSn: '', + linkNm: '', + }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLinkSearchParam({ + ...linkSearchParam, + linkSn, + linkNm + }); + }; + + const handleRowClick = (link: Link) => { + queryClient.removeQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`], + }); + queryClient.removeQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkInfo`], + }); + router.push(`link/register/${link.link_sn}`); + }; + + const handleClickRegist = () => { + router.push('link/register'); + }; + + const { tableComponent } = useDataTable({ + query, + tableOptions: { + columns, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + }, + dataTableOptions: { + onRowClick: handleRowClick, + viewPagination: true, + }, + }); + + return ( +
+
+
+ + setLinkSn(e.target.value)} + autoComplete="off" + /> + + setLinkNm(e.target.value)} + autoComplete="off" + /> + + +
+
+
+
+

연계 목록

+
+ +
+
+
+ {!query.isLoading ? +
{tableComponent}
+ : +
+
+ +
+
+ } +
+
+
+ ); +} + +export { LinkPage }; \ No newline at end of file diff --git a/apps/cs/src/pages/link/ui/modify-page.tsx b/apps/cs/src/pages/link/ui/modify-page.tsx new file mode 100644 index 000000000..a15fecb97 --- /dev/null +++ b/apps/cs/src/pages/link/ui/modify-page.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { LinkApi, linkFormSchema, type LinkFormData } from "@/entities/link"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import LinkForm from "./link-form"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { useEffect } from "react"; +import { getUserInfo } from "../../../../../../packages/common/components/src/api"; +import { AUTH_USER_ID } from "@/shared/config"; +import { getCookie } from "@common/components"; + +function ModifyLinkPage({ linkSn }: { linkSn: string }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const { setMessage: alert } = useAlertStore((state) => state); + const userUniqId = getCookie(AUTH_USER_ID) ?? ""; + + // 컴포넌트 마운트 시 캐시 초기화 + useEffect(() => { + queryClient.removeQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkInfo`], + }); + }, [queryClient]); + + const { data: linkInfo, refetch } = useQuery({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkInfo`], + queryFn: () => LinkApi.getLinkInfo(linkSn), + enabled: !!linkSn, + }); + + const { mutateAsync: updateLinkInfo } = useMutation({ + mutationFn: (data: LinkFormData) => LinkApi.updateLinkInfo({ + ...data, + link_clsf_cd: data.link_clsf_cd !== 'none' ? data.link_clsf_cd : null, + mdfr: userUniqId + }), + onSuccess: () => { + alert('수정되었습니다.'); + router.push('/link'); + } + }); + + const { mutateAsync: deleteLink } = useMutation({ + mutationFn: () => LinkApi.deleteLink(linkSn), + onSuccess: () => { + alert('삭제되었습니다.', router.back); + } + }); + + const { mutateAsync: updateLinkResultCode } = useMutation({ + mutationFn: () => LinkApi.updateLinkResultCode(linkSn), + onSuccess: () => { + alert('대기로 전환되었습니다.'); + refetch(); + } + }); + + const fetchUserInfo = async (id: string) => { + const userData = await getUserInfo(); + return userData.userName; + }; + + const form = useForm({ + resolver: zodResolver(linkFormSchema), + defaultValues: {}, + }); + + useEffect(() => { + const applyFormData = async () => { + if (linkInfo) { + let newData = { ...linkInfo }; + + if (linkInfo.mdfr) { + const userName = await fetchUserInfo(linkInfo.mdfr); + newData = { + ...linkInfo, + mdfr: userName, + }; + } + + form.reset(newData); + + } + }; + + applyFormData(); + }, [linkInfo]); + + return ( + <> +
+
+

연계정보 수정

+
+ +
+ + + ); +}; + +export { ModifyLinkPage }; \ No newline at end of file diff --git a/apps/cs/src/pages/link/ui/regist-page.tsx b/apps/cs/src/pages/link/ui/regist-page.tsx new file mode 100644 index 000000000..6aa26e79e --- /dev/null +++ b/apps/cs/src/pages/link/ui/regist-page.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { LinkApi, type LinkFormData, linkFormSchema } from "@/entities/link"; +import LinkForm from "./link-form"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import dayjs from "dayjs"; +import { useMutation } from "@tanstack/react-query"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { useRouter } from "next/navigation"; +import { AUTH_USER_ID } from "@/shared/config"; +import { getCookie } from "@common/components"; + +function RegistLinkPage() { + const { setMessage: alert } = useAlertStore((state) => state); + const router = useRouter(); + const userUniqId = getCookie(AUTH_USER_ID) ?? ""; + + const form = useForm({ + resolver: zodResolver(linkFormSchema), + defaultValues: { + link_nm: '', + link_url: '', + link_srvr_ip: '', + link_db_cd: '', + link_key: '', + link_id: '', + link_pswd: '', + srvc_usg: '', + link_batch_time: 5, + link_clsf_cd: 'none', + }, + }); + + const { mutate: insertLinkInfo } = useMutation({ + mutationFn: (data: LinkFormData) => LinkApi.insertLinkInfo(data), + onSuccess: () => { + alert('저장되었습니다.'); + router.push('/link'); + } + }); + + const handleInsertLink = (data: LinkFormData) => { + let insertData = { + ...data, + link_clsf_cd: data.link_clsf_cd !== 'none' ? data.link_clsf_cd : null, + next_link_excn_dt: dayjs().add(Number(data.link_batch_time), 'minute').format('YYYY-MM-DD HH:mm:ss'), + mdfr: userUniqId, + }; + + insertLinkInfo(insertData); + }; + + return ( +
+
+

연계 등록

+
+ +
+ ); +}; + +export { RegistLinkPage }; \ No newline at end of file diff --git a/apps/cs/src/pages/linkLog/index.ts b/apps/cs/src/pages/linkLog/index.ts new file mode 100644 index 000000000..53ef1798e --- /dev/null +++ b/apps/cs/src/pages/linkLog/index.ts @@ -0,0 +1 @@ +export { LinkLogPage } from './ui/link-log-page'; \ No newline at end of file diff --git a/apps/cs/src/pages/linkLog/model/table-columns.tsx b/apps/cs/src/pages/linkLog/model/table-columns.tsx new file mode 100644 index 000000000..30e7fa702 --- /dev/null +++ b/apps/cs/src/pages/linkLog/model/table-columns.tsx @@ -0,0 +1,55 @@ +import dayjs from "dayjs"; +import { createColumnHelper } from "@tanstack/react-table"; +import { type LinkLog } from "@/entities/link"; + +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.accessor('rownum', { + id: 'rownum', + header: 'NO', + }), + columnHelper.accessor('link_sn', { + id: 'link_sn', + header: '연계일련번호', + }), + columnHelper.accessor('link_nm', { + id: 'link_nm', + header: '연계 명', + }), + columnHelper.display({ + id: 'link_rslt_cd', + header: '연계실행결과', + cell: (info) => { + const isSucc = info.row.original.link_rslt_cd.trim() === 'SUCC'; + return {isSucc ? '성공' : '실패'} + }, + }), + columnHelper.accessor('link_err_cd', { + id: 'link_err_cd', + header: '연계오류코드', + cell: (info) => (info.getValue() ? info.getValue() : '-'), + }), + columnHelper.accessor('err_cn', { + id: 'err_cn', + header: '연계오류메세지', + cell: (info) => ( +
+ {info.getValue() || '-'} +
+ ), + }), + columnHelper.accessor('log_crt_dt', { + id: 'log_crt_dt', + header: '연계실행일자', + cell: (info) => { + const date = dayjs(info.getValue()); + return date.format('YYYY-MM-DD HH:mm'); + } + }), +]; + +export { columns }; \ No newline at end of file diff --git a/apps/cs/src/pages/linkLog/ui/link-log-page.tsx b/apps/cs/src/pages/linkLog/ui/link-log-page.tsx new file mode 100644 index 000000000..dd39a4726 --- /dev/null +++ b/apps/cs/src/pages/linkLog/ui/link-log-page.tsx @@ -0,0 +1,189 @@ +"use client"; + +import "@common/assets/styles/grid.css"; + +import { useState } from "react"; +import { Button, Input, Label, DatePicker, Select, SelectTrigger, SelectContent, SelectItem, SelectValue, LoadingWithAnimation } from "@common/components"; +import { useQuery } from "@tanstack/react-query"; + +import { getCoreRowModel, type PaginationState, type RowSelectionState, } from "@tanstack/react-table"; +import { LinkApi, type LinkSearchParam } from "@/entities/link"; +import { columns } from "../model/table-columns"; +import dayjs from "dayjs"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { useDataTable } from "@/shared/lib"; + +function LinkLogPage() { + const { setMessage: alert } = useAlertStore((state) => state); + + const [linkSn, setLinkSn] = useState(''); + const [linkNm, setLinkNm] = useState(''); + const [isSucc, setIsSucc] = useState('ALL'); + const [startDate, setStartDate] = useState(dayjs().subtract(7, "day").format('YYYY-MM-DD')); + const [endDate, setEndDate] = useState(dayjs().format('YYYY-MM-DD')); + + const [linkSearchParam, setLinkSearchParam] = useState({ + linkSn: '', + linkNm: '', + isSucc: '', + startDate: '', + endDate: '', + }); + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 100, + }); + + const [rowSelection, setRowSelection] = useState({}); + + const query = useQuery({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, linkSearchParam, pagination], + queryFn: () => + LinkApi.getLinkLogList({ + ...linkSearchParam, + page: pagination.pageIndex, + size: pagination.pageSize, + isSucc, + startDate, + endDate, + }), + }); + + const { tableComponent } = useDataTable({ + query, + tableOptions: { + columns, + state: { + pagination, + rowSelection, + }, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }, + dataTableOptions: { + viewPagination: true, + }, + }); + + const handleResetSearchParam = () => { + setIsSucc('ALL'); + setLinkNm(''); + setLinkSn(''); + setStartDate(dayjs().subtract(7, "day").format('YYYY-MM-DD')); + setEndDate(dayjs().format('YYYY-MM-DD')); + setLinkSearchParam({ + ...linkSearchParam, + linkSn: '', + linkNm: '', + isSucc: 'ALL', + startDate: dayjs().subtract(7, "day").format('YYYY-MM-DD'), + endDate: dayjs().format('YYYY-MM-DD'), + }); + }; + + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); + + if (startDate && endDate) { + + if (startDate > endDate) { + alert('종료일이 시작일보다 이전일 수 없습니다.'); + return; + } else { + setPagination({ + ...pagination, + pageIndex: 0, + }); + setLinkSearchParam({ + ...linkSearchParam, + linkSn, + linkNm, + isSucc, + startDate, + endDate, + }); + } + } else { + alert('조회 날짜를 선택해주세요.'); + return; + } + }; + + return ( +
+
+
+ + setLinkSn(e.target.value)} + autoComplete="off" + /> + + setLinkNm(e.target.value)} + autoComplete="off" + /> + + + + setStartDate(dayjs(e).format('YYYY-MM-DD'))} /> + + setEndDate(dayjs(e).format('YYYY-MM-DD'))} /> + + + +
+
+
+

로그 목록 (검색 조건에 따른 건수 : {query.data?.totalElements.toLocaleString('ko-KR') ?? 0} )

+
+
+ {query.isLoading ? +
+
+ +
+
+ : +
{tableComponent}
+ } +
+
+
+ ) +}; + +export { LinkLogPage }; \ No newline at end of file diff --git a/apps/cs/src/pages/linkParams/index.ts b/apps/cs/src/pages/linkParams/index.ts new file mode 100644 index 000000000..bd233390f --- /dev/null +++ b/apps/cs/src/pages/linkParams/index.ts @@ -0,0 +1 @@ +export { LinkParamsPage } from './ui/link-params-page'; \ No newline at end of file diff --git a/apps/cs/src/pages/linkParams/model/table-columns.tsx b/apps/cs/src/pages/linkParams/model/table-columns.tsx new file mode 100644 index 000000000..a71a74e9d --- /dev/null +++ b/apps/cs/src/pages/linkParams/model/table-columns.tsx @@ -0,0 +1,26 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { type Link } from "@/entities/link"; + +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.accessor((_, index) => index + 1, { + id: 'no', + header: 'NO', + }), + columnHelper.accessor('link_sn', { + id: 'link_sn', + header: '연계 일련번호', + }), + columnHelper.accessor('link_nm', { + id: 'link_nm', + header: '연계 명', + }), + columnHelper.accessor('srvc_usg', { + id: 'srvc_usg', + header: '서비스용도', + cell: (info) => (info.getValue() ? info.getValue() : '-'), + }), +]; + +export { columns }; \ No newline at end of file diff --git a/apps/cs/src/pages/linkParams/model/vrbl-table-columns.tsx b/apps/cs/src/pages/linkParams/model/vrbl-table-columns.tsx new file mode 100644 index 000000000..26ccc7303 --- /dev/null +++ b/apps/cs/src/pages/linkParams/model/vrbl-table-columns.tsx @@ -0,0 +1,98 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { type LinkVrbl } from "@/entities/link"; +import { Checkbox } from "@common/components"; + +const columnHelper = createColumnHelper(); + +function vrblColumns({ + selectedVrblIds, + setSelectedVrblIds, +}: { + selectedVrblIds: string[]; + setSelectedVrblIds: React.Dispatch>; +}) { + return [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + const allRows = table.getRowModel().rows; + const allSelected = + allRows.length > 0 && + allRows.every((row) => + selectedVrblIds.includes(row.original.link_vrbl_sn) + ); + + return ( +
+ { + if (checked) { + setSelectedVrblIds(allRows.map((row) => row.original.link_vrbl_sn)); + } else { + setSelectedVrblIds([]); + } + }} + /> +
+ ); + }, + cell: ({ row }) => { + const id = row.original.link_vrbl_sn; + const checked = selectedVrblIds.includes(id); + + return ( +
+ e.stopPropagation()} + onCheckedChange={(checked) => { + setSelectedVrblIds((prev) => + checked ? [...prev, id] : prev.filter((x) => x !== id) + ); + }} + /> +
+ ); + }, + }), + + columnHelper.accessor("link_vrbl_sn", { + id: "link_vrbl_sn", + header: "연계 변수 일련번호", + }), + columnHelper.accessor("link_vrbl_nm", { + id: "link_vrbl_nm", + header: "변수 명", + }), + columnHelper.accessor("link_vrbl_cd", { + id: "link_vrbl_cd", + header: "변수코드", + cell: (info) => (info.getValue() ? info.getValue() : '-'), + }), + columnHelper.accessor("link_vrbl_vl", { + id: "link_vrbl_vl", + header: "변수 값", + cell: (info) => (info.getValue() === 'now' ? '요청일자' : info.getValue() === 'nowMonth' ? '요청월' : info.getValue()), + }), + columnHelper.accessor("link_vrbl_expln", { + id: "link_vrbl_expln", + header: "설명", + cell: (info) => ( +
+ {info.getValue() || '-'} +
+ ), + }), + ]; +} + +export { vrblColumns }; \ No newline at end of file diff --git a/apps/cs/src/pages/linkParams/ui/link-params-dialog.tsx b/apps/cs/src/pages/linkParams/ui/link-params-dialog.tsx new file mode 100644 index 000000000..f3fe466b8 --- /dev/null +++ b/apps/cs/src/pages/linkParams/ui/link-params-dialog.tsx @@ -0,0 +1,347 @@ +'use client'; + +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, + Button, + Input, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Checkbox, + RadioGroup, + RadioGroupItem, + Label +} from "@common/components"; +import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form"; +import { type LinkVrblFormData } from "@/entities/link"; +import dayjs from "dayjs"; +import type { UseFormReturn } from "react-hook-form"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useState, useEffect } from "react"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; + +type LinkParamsDialogProps = { + selectedLink: { + linkSn: string; + linkNm: string; + }; + form: UseFormReturn; + insertLinkVrbl: (data: LinkVrblFormData) => void; + dialogOpen: boolean; + onOpenChange: (val: boolean) => void; + updateLinkVrbl: (data: LinkVrblFormData) => void; +} + +function LinkParamsDialog({ selectedLink, form, insertLinkVrbl, dialogOpen, onOpenChange, updateLinkVrbl }: LinkParamsDialogProps) { + + const isModifyMode = !!form.getValues('link_vrbl_sn'); + + const { confirm } = useConfirm(); + const [onConfirm, setOnConfirm] = useState(false); + const [isToday, setIsToday] = useState(false); + const [checkNowMonth, setCheckNowMonth] = useState('now'); + + const handleConfirm = async (callback: () => Promise) => { + setOnConfirm(true); + await callback(); + setOnConfirm(false); + }; + + function replaceEmptyWithNull>(data: T): T { + const result = {} as T; + for (const [key, value] of Object.entries(data)) { + result[key as keyof T] = value === '' ? null : value; + } + return result; + }; + + const handleSubmit = async (data: LinkVrblFormData) => { + const confirmMsg = isModifyMode ? '수정 하시겠습니까?' : '저장 하시겠습니까?'; + + await handleConfirm(async () => { + const cleanData = replaceEmptyWithNull(data); + + if (await confirm(confirmMsg)) { + isModifyMode ? updateLinkVrbl(isToday ? { ...cleanData, link_vrbl_vl: checkNowMonth } : cleanData) : insertLinkVrbl(isToday ? { ...cleanData, link_vrbl_vl: checkNowMonth } : cleanData); + } + }); + }; + + const handleCheckTodayValue = (check: boolean | string) => { + if (check) { + setIsToday(true); + form.setValue('link_vrbl_vl', (checkNowMonth === 'now' ? '요청일자' : '요청월')); + } else { + setIsToday(false); + form.setValue('link_vrbl_vl', ''); + } + }; + + useEffect(() => { + if (!dialogOpen) { + setIsToday(false); + setCheckNowMonth('now'); + form.reset({ + link_vrbl_nm: '', + link_vrbl_expln: '', + link_vrbl_cd: '', + link_vrbl_vl: '', + }); + } + + if (isModifyMode && (form.getValues('link_vrbl_vl') === 'now' || form.getValues('link_vrbl_vl') === 'nowMonth')) { + setIsToday(true); + setCheckNowMonth(form.getValues('link_vrbl_vl')); + } + + }, [dialogOpen]); + + return ( + { + onOpenChange(open); + }} + > + + + + + + + {selectedLink.linkNm} ({selectedLink.linkSn}) + + +
+ +
+ + + + + + + {isModifyMode && + + 변수 일련번호 + + ( + + + + + + )} + /> + + + } + + * 변수 명 + + ( + + + + + {form.formState.errors.link_vrbl_nm && ( +

+ {form.formState.errors.link_vrbl_nm.message} +

+ )} +
+ )} + /> +
+
+ + 변수 설명 + + ( + + + + + {form.formState.errors.link_vrbl_expln && ( +

+ {form.formState.errors.link_vrbl_expln.message} +

+ )} +
+ )} + /> +
+
+ + 변수 코드 + + ( + + + + + {form.formState.errors.link_vrbl_cd && ( +

+ {form.formState.errors.link_vrbl_cd.message} +

+ )} +
+ )} + /> +
+
+ + * 변수 값 + + ( + +
+ handleCheckTodayValue(check)} /> + +
+ {isToday && +
+ { + setCheckNowMonth(value); + form.setValue('link_vrbl_vl', (value === 'now' ? '요청일자' : '요청월')); + }} + > +
+ + +
+
+ + +
+
+
+ } + + + + {form.formState.errors.link_vrbl_vl && ( +

+ {form.formState.errors.link_vrbl_vl.message} +

+ )} +
+ )} + /> +
+
+ {isModifyMode && + <> + + 수정자 + + ( + + + + + + )} + /> + + + + 수정일시 + + ( + + + + + + )} + /> + + + + } +
+
+
+ + + + + + +
+ + +
+
+ ) +}; + +export { LinkParamsDialog }; \ No newline at end of file diff --git a/apps/cs/src/pages/linkParams/ui/link-params-page.tsx b/apps/cs/src/pages/linkParams/ui/link-params-page.tsx new file mode 100644 index 000000000..82273de25 --- /dev/null +++ b/apps/cs/src/pages/linkParams/ui/link-params-page.tsx @@ -0,0 +1,405 @@ +'use client'; + +import { useState, useMemo } from "react"; +import { + Button, + DataTable, + Input, + Label, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + LoadingWithAnimation, +} from "@common/components"; +import { getCoreRowModel, useReactTable, type PaginationState, type RowSelectionState, } from "@tanstack/react-table"; +import { LinkApi, type Link, type LinkSearchParam, type LinkVrblFormData, linkVrblFormSchema } from "@/entities/link"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { columns } from "../model/table-columns"; +import { vrblColumns } from "../model/vrbl-table-columns"; +import { type LinkVrbl } from "@/entities/link"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { LinkParamsDialog } from "./link-params-dialog"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { getUserInfo } from "../../../../../../packages/common/components/src/api"; +import { AUTH_USER_ID } from "@/shared/config"; +import { getCookie } from "@common/components"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import { useDataTable } from "@/shared/lib"; + +function LinkParamsPage() { + const queryClient = useQueryClient(); + + const [linkSn, setLinkSn] = useState(''); + const [linkNm, setLinkNm] = useState(''); + + const [linkSearchParam, setLinkSearchParam] = useState({ + linkSn: '', + linkNm: '' + }); + + const [selectedLink, setSelectedLink] = useState<{ + linkSn: string; + linkNm: string; + }>({ + linkSn: '', + linkNm: '' + }); + + const [rowSelection, setRowSelection] = useState({}); + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 100, + }); + + const { confirm } = useConfirm(); + + const [selectedVrblIds, setSelectedVrblIds] = useState([]); + const [isDBdata, setIsDBData] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [onConfirm, setOnConfirm] = useState(false); + + const userUniqId = getCookie(AUTH_USER_ID) ?? ""; + const { setMessage: alert } = useAlertStore((state) => state); + + const dbIdList = ['link0001', 'link0002']; + + // Link 목록 쿼리 + const query = useQuery({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, linkSearchParam], + queryFn: () => LinkApi.getLinkList({ + ...linkSearchParam, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + }); + + // LinkVrbl 목록 쿼리 - 별도 관리 + const linkVrblQuery = useQuery({ + queryKey: ['linkVrblList', selectedLink.linkSn], + queryFn: () => LinkApi.getLinkVrblList(selectedLink.linkSn), + enabled: !!selectedLink.linkSn && !dbIdList.includes(selectedLink.linkSn), + staleTime: 0, // 항상 최신 데이터를 가져오도록 설정 + }); + + const handleResetSearchParam = () => { + setSelectedLink({ + linkSn: '', + linkNm: '' + }); + + setLinkSn(''); + setLinkNm(''); + setLinkSearchParam({ + ...linkSearchParam, + linkSn: '', + linkNm: '' + }); + }; + + const handleConfirm = async (callback: () => Promise) => { + setOnConfirm(true); + await callback(); + setOnConfirm(false); + }; + + const { mutateAsync: insertLinkVrbl } = useMutation({ + mutationFn: async (data: LinkVrblFormData) => + await LinkApi.insertLinkVrbl({ + ...data, + link_sn: selectedLink.linkSn, + mdfr: userUniqId, + }), + onSuccess: async () => { + // 쿼리 무효화를 통한 최신 데이터 가져오기 + await queryClient.invalidateQueries({ + queryKey: ['linkVrblList', selectedLink.linkSn] + }); + setDialogOpen(false); + alert('저장되었습니다.'); + } + }); + + const { mutateAsync: updateLinkVrbl } = useMutation({ + mutationFn: async (data: LinkVrblFormData) => await LinkApi.updateLinkVrblInfo({ + ...data, + link_sn: selectedLink.linkSn, + mdfr: userUniqId, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['linkVrblList', selectedLink.linkSn] + }); + setDialogOpen(false); + alert('저장되었습니다.'); + } + }); + + const { mutateAsync: deleteLinkVrbl } = useMutation({ + mutationFn: async (data: string[]) => await LinkApi.deleteLinkVrbl(data), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['linkVrblList', selectedLink.linkSn] + }); + setSelectedVrblIds([]); + alert('삭제되었습니다.'); + } + }); + + // linkVrblQuery의 데이터를 사용 + const tableData = useMemo(() => { + return linkVrblQuery.data?.content ?? []; + }, [linkVrblQuery.data]); + + const vrblCols = useMemo( + () => + vrblColumns({ + selectedVrblIds, + setSelectedVrblIds, + }), + [selectedVrblIds] + ); + + const vrblTable = useReactTable({ + data: tableData, + columns: vrblCols, + getCoreRowModel: getCoreRowModel(), + }); + + const form = useForm({ + resolver: zodResolver(linkVrblFormSchema), + defaultValues: {}, + }); + + const handleRowClick = async (link: Link) => { + setSelectedLink({ + linkSn: link.link_sn, + linkNm: link.link_nm, + }); + + setSelectedVrblIds([]); + + if (dbIdList.includes(link.link_clsf_cd)) { + setIsDBData(true); + } else { + setIsDBData(false); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + setSelectedLink({ + linkSn: '', + linkNm: '' + }); + + setLinkSearchParam({ + ...linkSearchParam, + linkSn, + linkNm + }); + }; + + const fetchUserInfo = async (id: string) => { + const userData = await getUserInfo(); + return userData.userName; + }; + + const handleDeleteParam = async () => { + if (selectedVrblIds.length === 0) { + alert('삭제할 파라미터를 선택해 주세요.'); + } else { + await handleConfirm(async () => { + if (await confirm('선택한 파라미터 정보를 삭제하시겠습니까?')) { + deleteLinkVrbl(selectedVrblIds); + } + }); + } + }; + + const handleVrblRowClick = async (data: LinkVrbl) => { + const linkVrblInfo = await LinkApi.getLinkVrblInfo(data.link_vrbl_sn); + + if (linkVrblInfo) { + let newData = { ...linkVrblInfo }; + + if (linkVrblInfo.mdfr) { + const userName = await fetchUserInfo(linkVrblInfo.mdfr); + newData = { + ...linkVrblInfo, + mdfr: userName, + }; + } + + form.reset(newData); + } + setDialogOpen(true); + }; + + const { tableComponent } = useDataTable({ + query, + tableOptions: { + columns, + state: { + pagination, + rowSelection, + }, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }, + dataTableOptions: { + onRowClick: handleRowClick, + viewPagination: true, + }, + }); + + // LinkVrbl 데이터 표시 로직 + const renderLinkVrblContent = () => { + if (isDBdata) { + return ( +
+ 변수를 지원하지 않는 연계 데이터입니다. +
+ ); + } + + if (!selectedLink.linkSn) { + return ( +
+ 연계 데이터를 선택해주세요. +
+ ); + } + + if (linkVrblQuery.isLoading) { + return ( +
+
+ +
+
+ ); + } + + return ( + <> +
+ {tableData.length > 0 && ( + + )} + +
+ {tableData.length > 0 ? ( +
+ +
+ ) : ( +
+ 등록된 연계 변수가 없습니다. +
+ )} + + ); + }; + + return ( +
+
+
+ + setLinkSn(e.target.value)} + autoComplete="off" + /> + + setLinkNm(e.target.value)} + autoComplete="off" + /> + + +
+
+
+
+
+

연계 목록

+
+
+ {!query.isLoading ? ( +
{tableComponent}
+ ) : ( +
+
+ +
+
+ )} +
+
+
+
+

연계 변수 정보

+
+
+ + + + + + + + 연계 일련번호 + + {selectedLink.linkSn ? selectedLink.linkSn : '-'} + + + + 연계 명 + + {selectedLink.linkNm ? selectedLink.linkNm : '-'} + + + +
+ {renderLinkVrblContent()} +
+
+
+ +
+ ); +} + +export { LinkParamsPage }; \ No newline at end of file diff --git a/apps/cs/src/pages/linkStats/index.ts b/apps/cs/src/pages/linkStats/index.ts new file mode 100644 index 000000000..4e3241a79 --- /dev/null +++ b/apps/cs/src/pages/linkStats/index.ts @@ -0,0 +1 @@ +export { LinkStatsPage } from './ui/link-stats-page'; \ No newline at end of file diff --git a/apps/cs/src/pages/linkStats/ui/link-stats-page.tsx b/apps/cs/src/pages/linkStats/ui/link-stats-page.tsx new file mode 100644 index 000000000..547b3766f --- /dev/null +++ b/apps/cs/src/pages/linkStats/ui/link-stats-page.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import dayjs from "dayjs"; +import { LinkApi, type LinkStats, type LinkSearchParam } from "@/entities/link"; +import { Button, Label, DatePicker, LoadingWithAnimation } from "@common/components"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Tooltip, + Legend, + type ChartOptions, + type Chart, +} from "chart.js"; +import { Line } from "react-chartjs-2"; +import { getCoreRowModel, type PaginationState, type RowSelectionState, } from "@tanstack/react-table"; +import { columns } from "@/pages/linkLog/model/table-columns"; +import { useDataTable } from "@/shared/lib"; +import { useQuery } from "@tanstack/react-query"; + +interface LinkStatsListObject { + linkSuccStats: LinkStats[] | null; + linkFailStats: LinkStats[] | null; +} + +function LinkStatsPage() { + + const succChartRef = useRef | null>(null); + const failChartRef = useRef | null>(null); + + const [startDate, setStartDate] = useState(dayjs().subtract(7, "day").format('YYYY-MM-DD')); + const [endDate, setEndDate] = useState(dayjs().format('YYYY-MM-DD')); + + const [rowSelection, setRowSelection] = useState({}); + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 100, + }); + + const [linkStatsList, setLinkStatsList] = useState(null); + const [logQueryParam, setLogQueryParam] = useState<{ + isSucc: 'SUCC' | 'FAIL'; + linkSn?: string; + linkNm?: string; + } | null>(null); + + const query = useQuery({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, logQueryParam, pagination], + queryFn: () => + LinkApi.getLinkLogList({ + ...logQueryParam, + page: pagination.pageIndex, + size: pagination.pageSize, + startDate, + endDate, + }), + enabled: !!logQueryParam, + }); + + + const { tableComponent } = useDataTable({ + query, + tableOptions: { + columns, + state: { + pagination, + rowSelection, + }, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }, + dataTableOptions: { + viewPagination: true, + }, + }); + + const [linkSearchParam, setLinkSearchParam] = useState({ + linkSn: '', + linkNm: '', + startDate: '', + endDate: '', + }); + + ChartJS.register( + LineElement, + CategoryScale, + LinearScale, + PointElement, + Tooltip, + Legend + ); + + const succData = { + labels: linkStatsList?.linkSuccStats?.map((e) => e.link_nm), + datasets: [ + { + label: '연계 성공', + data: linkStatsList?.linkSuccStats?.map((e) => e.count), + fill: false, + borderColor: 'rgb(53, 162, 235)', + backgroundColor: 'rgb(53, 162, 235)', + tension: 0.1, + pointRadius: 6, + pointHoverRadius: 8, + }, + ], + }; + + const succOptions: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + }, + }, + scales: { + y: { + min: 0, + }, + }, + }; + + const failData = { + labels: linkStatsList?.linkFailStats?.map((e) => e.link_nm), + datasets: [ + { + label: '연계 실패', + data: linkStatsList?.linkFailStats?.map((e) => e.count), + fill: false, + borderColor: 'rgb(255, 99, 132)', + backgroundColor: 'rgb(255, 99, 132)', + tension: 0.1, + pointRadius: 6, + pointHoverRadius: 8, + }, + ], + }; + + const failOptions: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + }, + }, + scales: { + y: { + min: 0, + }, + }, + }; + + const handleResetSearchParam = async () => { + setLinkSearchParam({ + linkSn: '', + linkNm: '', + startDate: dayjs().subtract(7, "day").format('YYYY-MM-DD'), + endDate: dayjs().format('YYYY-MM-DD'), + }); + + setLogQueryParam({ + isSucc: 'SUCC', + linkNm: '', + linkSn: '', + }); + + setStartDate(dayjs().subtract(7, "day").format('YYYY-MM-DD')); + setEndDate(dayjs().format('YYYY-MM-DD')); + + const data = await LinkApi.getLinkStatsList({ + ...linkSearchParam, + linkSn: '', + linkNm: '', + startDate: dayjs().subtract(7, "day").format('YYYY-MM-DD'), + endDate: dayjs().format('YYYY-MM-DD'), + }); + setLinkStatsList(data); + }; + + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); + + if (startDate && endDate) { + + if (startDate > endDate) { + alert('종료일이 시작일보다 이전일 수 없습니다.'); + return; + } else { + const data = await LinkApi.getLinkStatsList({ + ...linkSearchParam, + startDate, + endDate + }); + setLinkStatsList(data); + setLogQueryParam({ + isSucc: 'SUCC', + linkNm: '', + linkSn: '', + }); + setLinkSearchParam({ + ...linkSearchParam, + startDate, + endDate + }); + } + } else { + alert('조회 날짜를 선택해주세요.'); + return; + } + }; + + const handleSuccChartClick = async (event: React.MouseEvent) => { + if (!succChartRef.current) return; + + const chart = succChartRef.current; + + const points = chart.getElementsAtEventForMode( + event.nativeEvent, + "nearest", + { intersect: true }, + false + ); + + if (points[0] && linkStatsList?.linkSuccStats) { + const index = points[0].index; + setPagination({ + ...pagination, + pageIndex: 0, + pageSize: 100, + }); + setLogQueryParam({ + isSucc: 'SUCC', + linkNm: linkStatsList.linkSuccStats[index]?.link_nm, + linkSn: linkStatsList.linkSuccStats[index]?.link_sn, + }); + } + }; + + const handleFailChartClick = async (event: React.MouseEvent) => { + if (!failChartRef.current) return; + + const chart = failChartRef.current; + + const points = chart.getElementsAtEventForMode( + event.nativeEvent, + "nearest", + { intersect: true }, + false + ); + + if (points[0] && linkStatsList?.linkFailStats) { + const index = points[0].index; + setPagination({ + ...pagination, + pageIndex: 0, + pageSize: 100, + }); + setLogQueryParam({ + isSucc: 'FAIL', + linkNm: linkStatsList.linkFailStats[index]?.link_nm, + linkSn: linkStatsList.linkFailStats[index]?.link_sn, + }); + } + }; + + const handleSubmitToday = async () => { + const data = await LinkApi.getLinkStatsList({ + ...linkSearchParam, + startDate: dayjs().format('YYYY-MM-DD'), + endDate: dayjs().format('YYYY-MM-DD'), + }); + setEndDate(dayjs().format('YYYY-MM-DD')); + setStartDate(dayjs().format('YYYY-MM-DD')), + setLinkStatsList(data); + setLinkSearchParam({ + ...linkSearchParam, + startDate: dayjs().format('YYYY-MM-DD'), + endDate: dayjs().format('YYYY-MM-DD'), + }); + setLogQueryParam({ + isSucc: 'SUCC', + linkNm: '', + linkSn: '', + }); + }; + + useEffect(() => { + const fetchData = async () => { + const data = await LinkApi.getLinkStatsList({ + ...linkSearchParam, + startDate, + endDate + }); + setLinkStatsList(data); + }; + + setLinkSearchParam({ + ...linkSearchParam, + startDate, + endDate + }); + + fetchData(); + }, []); + + return ( +
+
+
+ + setStartDate(dayjs(e).format('YYYY-MM-DD'))} /> + + setEndDate(dayjs(e).format('YYYY-MM-DD'))} /> + + + + +
+
+
+
+

조회 일자 : {`${linkSearchParam.startDate} ~ ${linkSearchParam.endDate}`}

+
+
+ +
+
+ +
+
+
+
+

+ {logQueryParam?.linkNm ? `${logQueryParam.linkNm} 연계 ${logQueryParam.isSucc === 'FAIL' ? '실패' : '성공'} 로그 목록 (${linkSearchParam.startDate} ~ ${linkSearchParam.endDate}) - ${query.data?.totalElements.toLocaleString('ko-KR') ?? 0}건` : '로그 목록'} +

+
+
+ {logQueryParam?.linkNm ? + query.isLoading ? +
+
+ +
+
+ : +
{tableComponent}
+ : +
+ 차트 데이터를 선택해주세요. +
+ } +
+
+
+
+ ) +}; + +export { LinkStatsPage }; \ No newline at end of file diff --git a/apps/cs/src/pages/linkTable/index.ts b/apps/cs/src/pages/linkTable/index.ts new file mode 100644 index 000000000..4677f1b52 --- /dev/null +++ b/apps/cs/src/pages/linkTable/index.ts @@ -0,0 +1 @@ +export { LinkTablePage} from "./ui/link-table-page"; \ No newline at end of file diff --git a/apps/cs/src/pages/linkTable/model/table-columns.tsx b/apps/cs/src/pages/linkTable/model/table-columns.tsx new file mode 100644 index 000000000..8c15ecd4b --- /dev/null +++ b/apps/cs/src/pages/linkTable/model/table-columns.tsx @@ -0,0 +1,103 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { type LinkTableColumns } from "@/entities/link"; +import { Checkbox } from "@common/components"; + +const columnHelper = createColumnHelper(); + +function tableColumns({ selectedColIds, setSelectedColIds }: { + selectedColIds: string[]; + setSelectedColIds: React.Dispatch>; +}) { + return [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + const allRows = table.getRowModel().rows; + const allSelected = + allRows.length > 0 && + allRows.every((row) => + selectedColIds.includes(row.original.col_sn) + ); + + return ( +
+ { + if (checked) { + setSelectedColIds(allRows.map((row) => row.original.col_sn)); + } else { + setSelectedColIds([]); + } + }} + /> +
+ ); + }, + cell: ({ row }) => { + const id = row.original.col_sn; + const checked = selectedColIds.includes(id); + + return ( +
+ e.stopPropagation()} + onCheckedChange={(checked) => { + setSelectedColIds((prev) => + checked ? [...prev, id] : prev.filter((x) => x !== id) + ); + }} + /> +
+ ); + }, + }), + columnHelper.accessor('col_sn', { + id: 'col_sn', + header: '컬럼 일련번호' + }), + columnHelper.accessor('sou_col_nm', { + id: 'sou_col_nm', + header: '원천컬럼명', + cell: (info) => info.getValue() ? info.getValue() : '-', + }), + columnHelper.accessor('sou_col_korn_nm', { + id: 'sou_col_korn_nm', + header: '원천컬럼 한글명', + cell: (info) => info.getValue() ? info.getValue() : '-', + }), + columnHelper.accessor('sou_col_sz', { + id: 'sou_col_sz', + header: '원천컬럼 크기', + cell: (info) => info.getValue() ? info.getValue() : '-', + }), + columnHelper.accessor('trsf_col_nm', { + id: 'trsf_col_nm', + header: '변환컬럼명', + cell: (info) => info.getValue() ? info.getValue() : '-', + }), + columnHelper.accessor('trsf_col_korn_nm', { + id: 'trsf_col_korn_nm', + header: '변환컬럼 한글명', + cell: (info) => info.getValue() ? info.getValue() : '-', + }), + columnHelper.accessor('trsf_col_sz', { + id: 'trsf_col_sz', + header: '변환컬럼 크기', + cell: (info) => info.getValue() ? info.getValue() : '-', + }), + columnHelper.accessor('pk_yn', { + id: 'pk_yn', + header: 'PK 여부', + cell: (info) => info.getValue() === 'Y' ? '예' : '아니오', + }), + ] +} + +export { tableColumns }; \ No newline at end of file diff --git a/apps/cs/src/pages/linkTable/ui/link-table-dialog.tsx b/apps/cs/src/pages/linkTable/ui/link-table-dialog.tsx new file mode 100644 index 000000000..9c27a38d9 --- /dev/null +++ b/apps/cs/src/pages/linkTable/ui/link-table-dialog.tsx @@ -0,0 +1,501 @@ +'use client'; + +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, + Button, + Input, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, + Checkbox +} from "@common/components"; +import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form"; +import { type LinkTableColData, type LinkTable } from "@/entities/link"; +import dayjs from "dayjs"; +import type { UseFormReturn } from "react-hook-form"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useState, useEffect } from "react"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; + +interface LinkTableDialogProps { + dialogOpen: boolean; + onOpenChange: (val: boolean) => void; + form: UseFormReturn; + tableInfo: LinkTable; + insertLinkTableCol: (data: LinkTableColData) => void; + updateLinkTableCol: (data: LinkTableColData) => void; +} + +function LinkTableDialog({ dialogOpen, onOpenChange, form, tableInfo, insertLinkTableCol, updateLinkTableCol }: LinkTableDialogProps) { + + const isModifyMode = !!form.getValues('col_sn'); + + const { confirm } = useConfirm(); + const [onConfirm, setOnConfirm] = useState(false); + const [useColSeq, setUseColSeq] = useState(false); + + const handleConfirm = async (callback: () => Promise) => { + setOnConfirm(true); + await callback(); + setOnConfirm(false); + }; + + const handleSubmit = async (data: LinkTableColData) => { + const confirmMsg = isModifyMode ? '수정 하시겠습니까?' : '저장 하시겠습니까?'; + + await handleConfirm(async () => { + if (await confirm(confirmMsg)) { + !isModifyMode + ? insertLinkTableCol({ + ...data, + link_sn: tableInfo.link_sn, + tbl_sn: tableInfo.tbl_sn, + }) + : updateLinkTableCol({ + ...data, + link_sn: tableInfo.link_sn, + tbl_sn: tableInfo.tbl_sn, + }); + } + }); + }; + + const handleCheckColSeqValue = (check: boolean | string) => { + if (check) { + setUseColSeq(false); + form.setValue('col_seq', null); + } else { + setUseColSeq(true); + } + }; + + useEffect(() => { + if (!dialogOpen) { + setUseColSeq(false); + form.reset({ + tbl_sn: '', + col_sn: '', + sou_col_nm: '', + sou_col_korn_nm: '', + sou_col_data_frm: '', + sou_col_sz: null, + trsf_col_nm: '', + trsf_col_korn_nm: '', + trsf_col_data_frm: '', + trsf_col_sz: null, + col_seq: null, + pk_yn: 'N', + mdfcn_dt: '', + mdfr: '', + }); + } + + if (isModifyMode && form.getValues('col_seq')) { + setUseColSeq(true); + } + + }, [dialogOpen]); + + return ( + { + onOpenChange(open); + }} + > + + + + + + + {tableInfo.trsf_tbl_korn_nm} ({tableInfo.tbl_sn}) + + +
+ +
+ + + + + + + {isModifyMode && + + 컬럼 일련번호 + + ( + + + + + + )} + /> + + + } + + * 원천컬럼명 + + ( + + + + + {form.formState.errors.sou_col_nm && ( +

+ {form.formState.errors.sou_col_nm.message} +

+ )} +
+ )} + /> +
+
+ + * 원천컬럼 한글명 + + ( + + + + + {form.formState.errors.sou_col_korn_nm && ( +

+ {form.formState.errors.sou_col_korn_nm.message} +

+ )} +
+ )} + /> +
+
+ + * 원천컬럼 데이터 형식 + + ( + + + + + {form.formState.errors.sou_col_data_frm && ( +

+ {form.formState.errors.sou_col_data_frm.message} +

+ )} +
+ )} + /> +
+
+ + 원천컬럼 크기 + + ( + + + { + const onlyDigits = e.target.value.replace(/\D/g, "").slice(0, 14); + field.onChange(onlyDigits); // react-hook-form에 반영 + }} + /> + +

+ ※ 14자리까지 입력 가능합니다. +

+
+ )} + /> +
+
+ + PK 여부 + + ( + + + + + + )} + /> + + + + * 변환컬럼명 + + ( + + + + + {form.formState.errors.trsf_col_nm && ( +

+ {form.formState.errors.trsf_col_nm.message} +

+ )} +
+ )} + /> +
+
+ + * 변환컬럼 한글명 + + ( + + + + + {form.formState.errors.trsf_col_korn_nm && ( +

+ {form.formState.errors.trsf_col_korn_nm.message} +

+ )} +
+ )} + /> +
+
+ + * 변환컬럼 데이터 형식 + + ( + + + + + {form.formState.errors.trsf_col_data_frm && ( +

+ {form.formState.errors.trsf_col_data_frm.message} +

+ )} +
+ )} + /> +
+
+ + 변환컬럼 크기 + + ( + + + { + const onlyDigits = e.target.value.replace(/\D/g, "").slice(0, 14); + field.onChange(onlyDigits); // react-hook-form에 반영 + }} + /> + +

+ ※ 14자리까지 입력 가능합니다. +

+
+ )} + /> +
+
+ + 컬럼 매칭 순서 + + ( + +
+ handleCheckColSeqValue(check)} /> + +
+ + { + const onlyDigits = e.target.value.replace(/\D/g, "").slice(0, 3); + field.onChange(onlyDigits); // react-hook-form에 반영 + }} + /> + +

+ ※ 3자리까지 입력 가능합니다. +

+
+ )} + /> +
+
+ {isModifyMode && + <> + + 수정자 + + ( + + + + + + )} + /> + + + + 수정일시 + + ( + + + + + + )} + /> + + + + } +
+
+
+ + + + + + +
+ + +
+
+ ) +} + +export { LinkTableDialog }; \ No newline at end of file diff --git a/apps/cs/src/pages/linkTable/ui/link-table-page.tsx b/apps/cs/src/pages/linkTable/ui/link-table-page.tsx new file mode 100644 index 000000000..cdb93b931 --- /dev/null +++ b/apps/cs/src/pages/linkTable/ui/link-table-page.tsx @@ -0,0 +1,710 @@ +'use client'; + +import { useState, useMemo } from "react"; +import { + Button, + DataTable, + Input, + Label, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + LoadingWithAnimation, +} from "@common/components"; +import { getCoreRowModel, type PaginationState, type RowSelectionState, useReactTable } from "@tanstack/react-table"; +import { LinkApi, type Link, type LinkSearchParam, type LinkTable, type LinkTableColumns, type LinkTableColData, type LinkTableFormData, LinkTableColumnsSchema, LinkTableSchema } from "@/entities/link"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { columns } from "../../linkParams/model/table-columns"; +import { tableColumns } from "../model/table-columns"; +import { LinkTableDialog } from "./link-table-dialog"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { getUserInfo } from "../../../../../../packages/common/components/src/api"; +import { AUTH_USER_ID } from "@/shared/config"; +import { getCookie } from "@common/components"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form"; +import { useDataTable } from "@/shared/lib"; + +function LinkTablePage() { + const queryClient = useQueryClient(); + + const userUniqId = getCookie(AUTH_USER_ID) ?? ""; + + const [linkSn, setLinkSn] = useState(''); + const [linkNm, setLinkNm] = useState(''); + + const [linkSearchParam, setLinkSearchParam] = useState({ + linkSn: '', + linkNm: '' + }); + + const [selectedTableInfo, setSelectedTableInfo] = useState(null); + const [tableColList, setTableColList] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedColIds, setSelectedColIds] = useState([]); + + const [rowSelection, setRowSelection] = useState({}); + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 100, + }); + + const { setMessage: alert } = useAlertStore((state) => state); + + const { confirm } = useConfirm(); + const [onConfirm, setOnConfirm] = useState(false); + + const handleConfirm = async (callback: () => Promise) => { + setOnConfirm(true); + await callback(); + setOnConfirm(false); + }; + + const query = useQuery({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, linkSearchParam], + queryFn: () => LinkApi.getLinkList({ + ...linkSearchParam, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + }); + + const { mutateAsync: insertLinkTableCol } = useMutation({ + mutationFn: async (data: LinkTableColData) => await LinkApi.insertLinkTableCol({ + ...data, + mdfr: userUniqId, + }), + onSuccess: async () => { + if (selectedTableInfo) { + await queryClient.invalidateQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo`] + }); + await queryClient.invalidateQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList`] + }); + + const tableData = await LinkApi.getLinkTableInfo(selectedTableInfo.link_sn); + const tableColList = await LinkApi.getLinkTableColList(selectedTableInfo.link_sn); + + setSelectedTableInfo(tableData); + setTableColList(tableColList.content); + setSelectedColIds([]); + setDialogOpen(false); + alert('저장되었습니다.'); + } + } + }); + + const { mutateAsync: updateLinkTableCol } = useMutation({ + mutationFn: async (data: LinkTableColData) => await LinkApi.updateLinkTableCol({ + ...data, + mdfr: userUniqId, + }), + onSuccess: async () => { + if (selectedTableInfo) { + await queryClient.invalidateQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo`] + }); + await queryClient.invalidateQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList`] + }); + + const tableData = await LinkApi.getLinkTableInfo(selectedTableInfo.link_sn); + const tableColList = await LinkApi.getLinkTableColList(selectedTableInfo.link_sn); + + setSelectedTableInfo(tableData); + setTableColList(tableColList.content); + setSelectedColIds([]); + setDialogOpen(false); + alert('수정되었습니다.'); + } + } + }); + + const { mutateAsync: deleteLinkTableCol } = useMutation({ + mutationFn: async (data: string[]) => await LinkApi.deleteLinkTableCol(data), + onSuccess: async () => { + if (selectedTableInfo) { + await queryClient.invalidateQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo`] + }); + await queryClient.invalidateQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList`] + }); + + const tableData = await LinkApi.getLinkTableInfo(selectedTableInfo.link_sn); + const tableColList = await LinkApi.getLinkTableColList(selectedTableInfo.link_sn); + + setSelectedTableInfo(tableData); + setTableColList(tableColList.content); + setSelectedColIds([]); + setDialogOpen(false); + alert('삭제되었습니다.'); + } + } + }); + + const { mutateAsync: insertLinkTableInfo } = useMutation({ + mutationFn: async (data: LinkTableFormData) => await LinkApi.insertLinkTableInfo(data), + onSuccess: async () => { + if (selectedTableInfo) { + await queryClient.invalidateQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo`] + }); + await queryClient.invalidateQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList`] + }); + + const tableData = await LinkApi.getLinkTableInfo(selectedTableInfo.link_sn); + const tableColList = await LinkApi.getLinkTableColList(selectedTableInfo.link_sn); + + tableForm.reset({ + link_sn: tableData.link_sn, + link_nm: tableData.link_nm, + tbl_sn: tableData.tbl_sn ?? "", + sou_tbl_nm: tableData.sou_tbl_nm ?? "", + sou_tbl_korn_nm: tableData.sou_tbl_korn_nm ?? "", + sou_tbl_expln: tableData.sou_tbl_expln ?? "", + trsf_tbl_expln: tableData.trsf_tbl_expln ?? "", + trsf_tbl_nm: tableData.trsf_tbl_nm ?? "", + trsf_tbl_korn_nm: tableData.trsf_tbl_korn_nm ?? "", + }); + setSelectedTableInfo(tableData); + setTableColList(tableColList.content); + setSelectedColIds([]); + alert('저장되었습니다.'); + } + } + }); + + const { mutateAsync: updateLinkTableInfo } = useMutation({ + mutationFn: async (data: LinkTableFormData) => await LinkApi.updateLinkTableInfo(data), + onSuccess: async () => { + if (selectedTableInfo) { + await queryClient.invalidateQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo`] + }); + await queryClient.invalidateQueries({ + queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList`] + }); + + const tableData = await LinkApi.getLinkTableInfo(selectedTableInfo.link_sn); + const tableColList = await LinkApi.getLinkTableColList(selectedTableInfo.link_sn); + + setSelectedTableInfo(tableData); + setTableColList(tableColList.content); + setSelectedColIds([]); + alert('수정되었습니다.'); + } + } + }); + + const tableCol = useMemo( + () => + tableColumns({ selectedColIds, setSelectedColIds }), + [selectedColIds] + ); + + const colTable = useReactTable({ + data: tableColList, + columns: tableCol, + getCoreRowModel: getCoreRowModel(), + }); + + const form = useForm({ + resolver: zodResolver(LinkTableColumnsSchema), + defaultValues: { + pk_yn: "N" + } + }); + + const tableForm = useForm({ + resolver: zodResolver(LinkTableSchema), + defaultValues: {}, + }); + + const handleResetSearchParam = async () => { + setSelectedTableInfo(null); + setTableColList([]); + setLinkSn(''); + setLinkNm(''); + setLinkSearchParam({ + ...linkSearchParam, + linkSn: '', + linkNm: '', + }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setSelectedTableInfo(null); + setTableColList([]); + + setLinkSearchParam({ + ...linkSearchParam, + linkSn, + linkNm + }); + }; + + const handleRowClick = async (data: Link) => { + const tableData = await LinkApi.getLinkTableInfo(data.link_sn); + const tableColList = await LinkApi.getLinkTableColList(data.link_sn); + + setSelectedTableInfo(tableData); + setTableColList(tableColList.content); + setSelectedColIds([]); + + tableForm.reset({ + link_sn: tableData.link_sn, + link_nm: tableData.link_nm, + tbl_sn: tableData.tbl_sn ?? "", + sou_tbl_nm: tableData.sou_tbl_nm ?? "", + sou_tbl_korn_nm: tableData.sou_tbl_korn_nm ?? "", + sou_tbl_expln: tableData.sou_tbl_expln ?? "", + trsf_tbl_expln: tableData.trsf_tbl_expln ?? "", + trsf_tbl_nm: tableData.trsf_tbl_nm ?? "", + trsf_tbl_korn_nm: tableData.trsf_tbl_korn_nm ?? "", + }); + }; + + const handleColumnRowClick = async (data: LinkTableColumns) => { + const colInfo = await LinkApi.getLinkTableColInfo({ colSn: data.col_sn, tblSn: data.tbl_sn }); + + if (colInfo) { + let newData = { ...colInfo }; + + if (colInfo.mdfr) { + const userName = await fetchUserInfo(colInfo.mdfr); + newData = { + ...colInfo, + mdfr: userName, + }; + } + + form.reset(newData); + + } + setDialogOpen(true) + }; + + const fetchUserInfo = async (id: string) => { + const userData = await getUserInfo(); + return userData.userName; + }; + + const handleDeleteParam = async () => { + if (selectedColIds.length === 0) { + alert('삭제할 컬럼을 선택해 주세요.'); + } else { + await handleConfirm(async () => { + if (await confirm('선택한 컬럼 정보를 삭제하시겠습니까?')) { + deleteLinkTableCol(selectedColIds); + } + }); + } + }; + + const handleTableSubmit = async (data: LinkTableFormData) => { + const regTblInfo = await LinkApi.countLinkTblNm(data.trsf_tbl_nm); + const confirmMsg = (regTblInfo.count > 0 && data.trsf_tbl_nm !== selectedTableInfo?.trsf_tbl_nm) ? `이미 사용중인 테이블 명입니다. 해당 테이블 명으로 ${!data.tbl_sn ? '저장' : '수정'}하시겠습니까?` : `${!data.tbl_sn ? '저장' : '수정'}하시겠습니까?`; + if (!data.tbl_sn) { + await handleConfirm(async () => { + if (await confirm(confirmMsg)) { + insertLinkTableInfo({ + ...data, + mdfr: userUniqId, + tbl_sn: (regTblInfo.count > 0) ? regTblInfo.tbl_sn : "", + }); + } + }); + } else { + await handleConfirm(async () => { + if (await confirm(confirmMsg)) { + updateLinkTableInfo({ + ...data, + tbl_sn: (regTblInfo.count > 0) ? regTblInfo.tbl_sn : "", + mdfr: userUniqId, + }); + } + }); + } + + }; + + const { tableComponent } = useDataTable({ + query, + tableOptions: { + columns, + state: { + pagination, + rowSelection, + }, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }, + dataTableOptions: { + onRowClick: handleRowClick, + viewPagination: true, + }, + }); + + return ( +
+
+
+ + setLinkSn(e.target.value)} + autoComplete="off" + /> + + setLinkNm(e.target.value)} + autoComplete="off" + /> + + +
+
+
+
+
+

연계 목록

+
+
+ {!query.isLoading ? +
{tableComponent}
+ : +
+
+ +
+
+ } +
+
+
+
+

연계 테이블 정보

+
+
+
+ + {selectedTableInfo && +
+ +
+ } + + + + + + + + 연계 일련번호 + + {selectedTableInfo ? + ( + + + + + + )} + /> + : + - + } + + + + 연계 명 + + {selectedTableInfo ? + ( + + + + + + )} + /> + : + - + } + + + + 원천 테이블 명 + + {selectedTableInfo ? + ( + + + + + {tableForm.formState.errors.sou_tbl_nm && ( +

+ {tableForm.formState.errors.sou_tbl_nm.message} +

+ )} +
+ )} + /> + : + - + } +
+
+ + 원천 테이블 한글명 + + {selectedTableInfo ? + ( + + + + + {tableForm.formState.errors.sou_tbl_korn_nm && ( +

+ {tableForm.formState.errors.sou_tbl_korn_nm.message} +

+ )} +
+ )} + /> + : + - + } +
+
+ + 원천 테이블 설명 + + {selectedTableInfo ? + ( + + + + + {tableForm.formState.errors.sou_tbl_expln && ( +

+ {tableForm.formState.errors.sou_tbl_expln.message} +

+ )} +
+ )} + /> + : + - + } +
+
+ + * 변환 테이블 명 + + {selectedTableInfo ? + ( + + + + + {tableForm.formState.errors.trsf_tbl_nm && ( +

+ {tableForm.formState.errors.trsf_tbl_nm.message} +

+ )} +
+ )} + /> + : + - + } +
+
+ + * 변환 테이블 한글명 + + {selectedTableInfo ? + ( + + + + + {tableForm.formState.errors.trsf_tbl_korn_nm && ( +

+ {tableForm.formState.errors.trsf_tbl_korn_nm.message} +

+ )} +
+ )} + /> + : + - + } +
+
+ + 변환 테이블 설명 + + {selectedTableInfo ? + ( + + + + + {tableForm.formState.errors.trsf_tbl_expln && ( +

+ {tableForm.formState.errors.trsf_tbl_expln.message} +

+ )} +
+ )} + /> + : + - + } +
+
+
+
+
+ + {selectedTableInfo ? + <> +
+ {tableColList.length > 0 && + + } + {selectedTableInfo.tbl_sn && + + } +
+ {selectedTableInfo.tbl_sn ? + (tableColList.length > 0 ? +
+ +
+ : +
+ 등록된 테이블 컬럼이 없습니다. +
) + : +
+ 테이블 정보를 등록해주세요. +
+ } + + : +
+ 연계 데이터를 선택해주세요. +
+ } +
+
+
+ +
+ ) +}; + +export { LinkTablePage }; \ No newline at end of file