st: feat: 일반검증 관리 기능

This commit is contained in:
2026-03-19 14:15:20 +09:00
parent 3184ea4386
commit 11cf1bf7c9
11 changed files with 1419 additions and 126 deletions
@@ -19,10 +19,6 @@ import DetailComponent from './generalVerification/DetailComponent';
import RegistDialog from './generalVerification/RegistDialog';
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod';
import { generalVerificationMasterSubmitSchema, type GeneralVerificationMasterSubmitFormData } from '@/entities/statistics/model/general-verification-master-submit-schema';
// import { useDataTable } from "@/shared/lib";
import { getGeneralVerificationColumns } from '@/components/util/column/management/GeneralVerificationColumn';
import type { GeneralverificationList } from '@/entities/statistics/model/general-verification-interface';
import { generalverificationApiService } from '@/entities/statistics/api/general-verification-service';
@@ -43,7 +39,7 @@ const GeneralVerificationManagement = () => {
const [vrfcMasterData, setVrfcMasterData] = useState<GeneralverificationList[]>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [isLoading, setIsLoading] = useState<boolean>(false);
const [selectedVrfcRow, setSelectedVrfcRow] = useState<GeneralverificationList | null>(null);
const [selectedVrfcRow, setSelectedVrfcRow] = useState<number | null>(null);
const [showRegistForm, setShowRegistForm] = useState<boolean>(false);
const { setMessage: alert } = useAlertStore((state) => state);
@@ -86,33 +82,15 @@ const GeneralVerificationManagement = () => {
data: vrfcMasterData,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.vrfcSn,
getRowId: (row) => String(row.vrfcSn),
state: { rowSelection },
onRowSelectionChange: setRowSelection,
});
const form = useForm<GeneralVerificationMasterSubmitFormData>({
resolver: zodResolver(generalVerificationMasterSubmitSchema),
defaultValues: {},
});
const handleVrfcMasterRowClick = async (row: GeneralverificationList) => {
try {
const response = await generalverificationApiService.getGeneralverificationInfo(row.vrfcSn);
if (response.status === 'success') {
setRowSelection({ [row.vrfcSn]: true });
setSelectedVrfcRow(row);
setShowRegistForm(false);
form.reset(response.data);
} else {
alert(response.message || '데이터 조회 중 오류가 발생했습니다.');
form.reset({});
}
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
}
setRowSelection({ [row.vrfcSn]: true });
setSelectedVrfcRow(row.vrfcSn);
setShowRegistForm(false);
};
const handleReset = () => {
@@ -122,11 +100,6 @@ const GeneralVerificationManagement = () => {
setShowRegistForm(false);
}
const handleClickRegist = () => {
// setRowSelection({});
// setSelectedVrfcRow(null);
};
useEffect(() => {
handleSearch();
}, []);
@@ -190,7 +163,7 @@ const GeneralVerificationManagement = () => {
<div className="card !my-0 flex flex-col !p-0">
<div className='flex items-center justify-end gap-[0.6rem] px-[1.2rem]'>
<RegistDialog open={showRegistForm} onOpenChange={setShowRegistForm} onSuccess={handleSearch}>
<Button className="h-[3.2rem] w-[96px]" type="button" onClick={handleClickRegist}>
<Button className="h-[3.2rem] w-[96px]" type="button">
등록
</Button>
</RegistDialog>
@@ -219,7 +192,7 @@ const GeneralVerificationManagement = () => {
}
</div>
</div>
<DetailComponent selectedVrfcRow={selectedVrfcRow} form={form} />
<DetailComponent selectedVrfcRow={selectedVrfcRow} />
</div>
</div>
</div>
@@ -0,0 +1,147 @@
import { useState, useEffect } from "react";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
Label,
Input,
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import { useAlertStore } from "@/shared/lib";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { getUserInfo } from "../../../../../../../packages/common/components/src/api";
import { LoadingWithAnimation } from "@common/components";
import { generalverificationApiService } from "@/entities/statistics/api/general-verification-service";
import type { GeneralverificationList } from "@/entities/statistics/model/general-verification-interface";
interface CopyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children?: React.ReactNode;
row?: GeneralverificationList;
/** 수정/등록 성공 시 부모에서 목록 재조회 등 실행 */
onSuccess?: () => void;
}
const CopyDialog = ({
children,
open,
onOpenChange,
row,
onSuccess,
...props
}: CopyDialogProps) => {
const [selectedCode, setSelectedCode] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const handleChangeCode = (value: string) => {
if (row?.trgtStatsCd === value) {
alert('원본과 동일한 통계항목으로 복사할 수 없습니다.');
return;
}
setSelectedCode(value);
};
const handleCopyItem = async () => {
if (!selectedCode) {
alert('복사 대상 통계항목을 선택해주세요.');
return;
}
if (await confirm('복사하시겠습니까?')) {
try {
setIsLoading(true);
const userInfo = await getUserInfo();
const response = await generalverificationApiService.CopyVrfcData({
vrfcSn: row?.vrfcSn ?? null,
targetStatsCd: selectedCode ?? '',
rgtrId: userInfo.userUnqId
});
if (response.status === 'success') {
alert('복사되었습니다.');
onOpenChange(false);
onSuccess?.();
} else {
alert(response.message || '데이터 복사 중 오류가 발생했습니다.');
}
} catch {
alert('데이터 복사 중 오류가 발생했습니다.')
} finally {
setIsLoading(false);
}
}
};
useEffect(() => {
if (!open) setSelectedCode('');
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange} {...props}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>
{row?.vrfcNm}
</DialogTitle>
</DialogHeader>
{isLoading &&
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
}
<div className="flex flex-col gap-4 m-2">
<div className="flex flex-col gap-2">
<Label className="text-label">
원본 통계항목
</Label>
<Input
value={row?.trgtStatsNm}
disabled
className="w-full"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">
대상 통계항목
</Label>
<CodeSelect
upCd="STITEM"
value={selectedCode}
onValueChange={handleChangeCode}
placeholder="선택"
className="w-full"
/>
</div>
</div>
<DialogFooter>
<Button size="lg" type="button" onClick={handleCopyItem}>
복사
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CopyDialog;
@@ -1,12 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { NoData } from '@/shared/ui/no-data';
import {
// Button,
// Label,
// Select,
// SelectTrigger,
// SelectContent,
// SelectValue,
// SelectItem,
Button,
Input,
Table,
TableBody,
@@ -14,79 +10,333 @@ import {
TableHead,
TableRow,
} from "@common/components";
import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form";
import FormulaComponents from './FormulaComponents';
import { LoadingWithAnimation } from '@common/components';
import FomulaDialog from './FomulaDialog';
import { type UseFormReturn } from "react-hook-form";
import { useAlertStore } from '@/shared/lib';
import { useConfirm } from '@frontend-opensource/use-react-hooks';
import type { GeneralverificationList } from '@/entities/statistics/model/general-verification-interface';
import { type GeneralVerificationMasterSubmitFormData } from '@/entities/statistics/model/general-verification-master-submit-schema';
import { generalverificationApiService } from '@/entities/statistics/api/general-verification-service';
import type { VerifyDetailData, VerifyDetailResponseDto } from '@/entities/statistics/model/general-verification-interface';
import { getUserInfo } from '../../../../../../../packages/common/components/src/api';
interface DetailComponentProps {
selectedVrfcRow: GeneralverificationList | null;
form: UseFormReturn<GeneralVerificationMasterSubmitFormData>;
selectedVrfcRow: number | null;
}
const DetailComponent = ({ selectedVrfcRow, form }: DetailComponentProps) => {
return (
<div className='card card-border w-1/2 !mx-0 flex flex-col'>
<div className="card-header">
<h3 className="text-2xl font-medium text-label">검증 상세 정보</h3>
</div>
<div className="card !m-4 h-full overflow-auto !p-0">
{selectedVrfcRow ?
<>
<div className='flex flex-col'>
<Form {...form}>
<form>
<div>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="25%" />
<col width="25%" />
<col width="25%" />
<col width="25%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>* 검증명</TableHead>
<TableCell>
<FormField
control={form.control}
name="vrfcNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem] w-full min-w-0"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.vrfcNm && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.vrfcNm.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
<TableHead>* 통계항목</TableHead>
<TableCell className='text-center'>{(selectedVrfcRow as GeneralverificationList)?.trgtStatsNm ?? "-"}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</form>
</Form>
</div>
</>
:
<NoData />
const DetailComponent = ({ selectedVrfcRow }: DetailComponentProps) => {
const [detailInfo, setDetailInfo] = useState<VerifyDetailData | null>(null);
const [formulaList, setFormulaList] = useState<(VerifyDetailResponseDto | null)[]>([]);
const [hasPersistedDetails, setHasPersistedDetails] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [showFomulaDialog, setShowFomulaDialog] = useState<boolean>(false);
const [selectedFormula, setSelectedFormula] = useState<VerifyDetailResponseDto | null>(null);
const [selectedFormulaIndex, setSelectedFormulaIndex] = useState<number>(0);
const { confirm } = useConfirm();
const { setMessage: alert } = useAlertStore((state) => state);
const persistedFormulaSignatureRef = useRef<string>('');
const normalizeFormulaListForCompare = (list: (VerifyDetailResponseDto | null)[]) => {
return list.map((row) =>
row
? {
vrfcDtlSn: row.vrfcDtlSn ?? null,
refStatsCd: row.refStatsCd ?? null,
kornArtopnEqtnCn: row.kornArtopnEqtnCn ?? '',
engArtopnEqtnCn: row.engArtopnEqtnCn ?? '',
cmprOpert: row.cmprOpert ?? null,
}
: null,
);
};
const handleSaveClick = async () => {
const trgtStatsCd = detailInfo?.commonInfo.trgtStatsCd;
if (!trgtStatsCd) {
alert('대상 통계항목(trgtStatsCd) 정보가 없습니다.');
return;
}
const rows = formulaList.map((row, idx) => ({
eqtnCombSeq: idx + 1,
trgtStatsCd,
refStatsCd: row?.refStatsCd ?? null,
kornArtopnEqtnCn: row?.kornArtopnEqtnCn ?? '',
engArtopnEqtnCn: row?.engArtopnEqtnCn ?? '',
cmprOpert: row?.cmprOpert ?? null,
}));
if (rows.length < 2) {
alert('수식 항목은 최소 2개 이상 등록해야 합니다.');
return;
}
const invalidIdx = rows.findIndex(
(r) => r.kornArtopnEqtnCn.trim().length === 0 || r.engArtopnEqtnCn.trim().length === 0,
);
if (invalidIdx !== -1) {
alert(`수식이 비어있는 항목이 있습니다.\n(순번: ${invalidIdx + 1})`);
return;
}
// 마지막을 제외한 항목들은 연산자(cmprOpert)가 반드시 있어야 함
if (rows.length > 1) {
const invalidOpertIdx = rows.slice(0, -1).findIndex((r) => r.cmprOpert == null);
if (invalidOpertIdx !== -1) {
alert(`연산자가 선택되지 않은 항목이 있습니다.\n(순번: ${invalidOpertIdx + 1})`);
return;
}
}
// 마지막 항목의 연산자는 무조건 null 처리
if (rows.length > 0) {
const lastRow = rows[rows.length - 1];
if (lastRow) lastRow.cmprOpert = null;
setFormulaList((prev) => {
if (prev.length === 0) return prev;
const last = prev[prev.length - 1];
if (!last) return prev;
if (last.cmprOpert == null) return prev;
const next = [...prev];
next[next.length - 1] = { ...last, cmprOpert: null };
return next;
});
}
if (await confirm('등록하시겠습니까?')) {
try {
const userInfo = await getUserInfo();
let response;
if (!hasPersistedDetails) {
response = await generalverificationApiService.RegistVrfcDetail({ vrfcSn: detailInfo?.commonInfo.vrfcSn ?? null, detailList: rows, rgtrId: userInfo.userUnqId });
} else {
response = await generalverificationApiService.UpdateVrfcDetail({ vrfcSn: detailInfo?.commonInfo.vrfcSn ?? null, detailList: rows, mdfrId: userInfo.userUnqId });
}
if (response?.status === 'success') {
alert('등록되었습니다.');
getVrfcDetail();
} else {
alert(response?.message || '데이터 등록 중 오류가 발생했습니다.');
}
} catch {
alert('데이터 등록 중 오류가 발생했습니다.');
}
}
};
const getVrfcDetail = async () => {
try {
setIsLoading(true);
const response = await generalverificationApiService.getVrfcDetail(selectedVrfcRow!);
if (response.status === 'success') {
setDetailInfo(response.data);
const list = response.data.verifyDetailResponseDtoList;
setFormulaList(list?.length ? [...list] : [null]);
setHasPersistedDetails((list?.length ?? 0) > 0);
// "기존 데이터" 스냅샷(서버 응답 기준)을 시그니처로 저장
persistedFormulaSignatureRef.current = JSON.stringify(
normalizeFormulaListForCompare(list?.length ? [...list] : [null]),
);
} else {
alert(response.message || '데이터 조회 중 오류가 발생했습니다.');
}
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
};
const handleClickVrfcDetail = (formula?: VerifyDetailResponseDto | null, index?: number) => {
setSelectedFormula(formula ?? null);
setSelectedFormulaIndex(index ?? 0);
setShowFomulaDialog(true);
};
const handleFormulaConfirm = (payload: { kornArtopnEqtnCn: string; engArtopnEqtnCn: string; refStatsCd: string | null }) => {
setFormulaList((prev) => {
const next = [...prev];
const i = selectedFormulaIndex;
if (i < 0 || i >= next.length) return prev;
const item = next[i];
next[i] = item
? {
...item,
kornArtopnEqtnCn: payload.kornArtopnEqtnCn,
engArtopnEqtnCn: payload.engArtopnEqtnCn,
refStatsCd: payload.refStatsCd,
}
: {
vrfcDtlSn: null,
refStatsCd: payload.refStatsCd,
refStatsNm: null,
kornArtopnEqtnCn: payload.kornArtopnEqtnCn,
engArtopnEqtnCn: payload.engArtopnEqtnCn,
cmprOpert: null,
};
return next;
});
setShowFomulaDialog(false);
setSelectedFormula(null);
};
const handleResetClick = async () => {
const currentSignature = JSON.stringify(normalizeFormulaListForCompare(formulaList));
if (currentSignature === persistedFormulaSignatureRef.current) {
alert('변경된 내용이 없습니다.');
return;
}
if (await confirm(`변경 전 데이터로 리셋하시겠습니까?\n등록 완료된 데이터는 등록 전 데이터를 불러올 수 없습니다.`)) {
getVrfcDetail();
}
};
useEffect(() => {
if (selectedVrfcRow != null) {
getVrfcDetail();
}
}, [selectedVrfcRow]);
return (
<>
<div className='card card-border w-1/2 !mx-0 flex flex-col'>
<div className="card-header">
<h3 className="text-2xl font-medium text-label">검증 상세 정보</h3>
</div>
<div className="card !m-4 h-full !p-0">
{selectedVrfcRow ?
isLoading ?
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
:
<div className='flex flex-col gap-12 m-2 h-full'>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="25%" />
<col width="25%" />
<col width="25%" />
<col width="25%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>검증명</TableHead>
<TableCell className='text-center'>{detailInfo?.commonInfo.vrfcNm ?? "-"}</TableCell>
<TableHead>통계항목</TableHead>
<TableCell className='text-center'>{detailInfo?.commonInfo.trgtStatsNm ?? "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>검증분류</TableHead>
<TableCell className='text-center'>{detailInfo?.commonInfo.vrfcClsfNm ?? "-"}</TableCell>
<TableHead>소수점처리방식</TableHead>
<TableCell className='text-center'>
{detailInfo?.commonInfo.dcptDigtCnt === 0 ?
'소수점 생략'
:
`${detailInfo?.commonInfo.dcptDigtCnt}자리에서 ${detailInfo?.commonInfo.dcptPrcsMthNm}`
}
</TableCell>
</TableRow>
</TableBody>
</Table>
<div className='flex flex-col gap-4 overflow-auto'>
<h3 className="font-bold text-title">수식 정보</h3>
{formulaList.map((data, index) => (
<FormulaComponents
key={data?.vrfcDtlSn ?? `empty-${index}`}
data={data}
index={index}
onOperatorChange={(v) => {
setFormulaList((prev) => {
const next = [...prev];
if (index < 0 || index >= next.length) return prev;
const current = next[index];
const cmprOpert = v === 'none' ? null : v;
next[index] = current
? { ...current, cmprOpert }
: {
vrfcDtlSn: null,
refStatsCd: null,
refStatsNm: null,
kornArtopnEqtnCn: '',
engArtopnEqtnCn: '',
cmprOpert,
};
return next;
});
}}
onDelete={() => {
setFormulaList((prev) => {
const next = prev.filter((_, i) => i !== index);
return next.length > 0 ? next : [null];
});
}}
handleFomulaDialog={(show, formula, idx) => {
if (show) handleClickVrfcDetail(formula, idx ?? index);
else setShowFomulaDialog(false);
}}
/>
))}
</div>
<div className='flex justify-center mb-4'>
<Button
type="button"
size="icon"
className="size-[4rem] rounded-full"
onClick={() => {
setFormulaList((prev) => [...prev, null]);
}}
>
<i className="ri-play-list-add-fill text-[16px]"></i>
</Button>
</div>
<div className="flex-1 flex gap-2 justify-end items-end">
<Button
type="button"
color='red'
title='리셋'
onClick={handleResetClick}
>
<i className="diveicon di-arrow-reset" />
</Button>
<Button
type="button"
onClick={handleSaveClick}
>
등록
</Button>
</div>
</div>
:
<div className="flex h-full items-center justify-center">
<NoData />
</div>
}
</div>
</div>
</div>
<FomulaDialog
key={showFomulaDialog ? (selectedFormula?.vrfcDtlSn ?? 'new') : 'closed'}
open={showFomulaDialog}
onOpenChange={(open) => {
setShowFomulaDialog(open);
if (!open) setSelectedFormula(null);
}}
data={selectedFormula}
trgtStatsCd={detailInfo?.commonInfo.trgtStatsCd}
trgtStatsNm={detailInfo?.commonInfo.trgtStatsNm}
onConfirm={handleFormulaConfirm}
/>
</>
);
};
@@ -0,0 +1,593 @@
import { useEffect, useRef, useState } from "react";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogClose,
Textarea,
Skeleton,
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import { generalverificationApiService } from "@/entities/statistics/api/general-verification-service";
import type { VerifyDetailResponseDto, VerifyColumnListResponseData, SelectedVerifyColumnListResponseData } from "@/entities/statistics/model/general-verification-interface";
import { useAlertStore } from "@/shared/lib";
export interface FomulaDialogConfirmPayload {
kornArtopnEqtnCn: string;
engArtopnEqtnCn: string;
/** 참조 통계코드 (선택 안 했으면 null) */
refStatsCd: string | null;
}
interface FomulaDialogProps {
open: boolean;
onOpenChange?: (open: boolean) => void;
data?: VerifyDetailResponseDto | null;
trgtStatsCd?: string;
trgtStatsNm?: string;
/** 확인 클릭 시 검사 통과 후 호출. 해당 행에 반영 후 다이얼로그 닫기는 부모에서 처리 */
onConfirm?: (payload: FomulaDialogConfirmPayload) => void;
}
const FomulaDialog = ({ open, onOpenChange, data, trgtStatsCd, trgtStatsNm, onConfirm }: FomulaDialogProps) => {
const [columnInfo, setColumnInfo] = useState<VerifyColumnListResponseData | null>(null);
const [selectedColumnInfo, setSelectedColumnInfo] = useState<SelectedVerifyColumnListResponseData | null>(null);
const [showCommonColumns, setShowCommonColumns] = useState<boolean>(true);
const [showTargetColumns, setShowTargetColumns] = useState<boolean>(true);
const [showRefColumns, setShowRefColumns] = useState<boolean>(true);
/** 참조 통계 - 다이얼로그 내부에서만 사용. 열 때마다 해당 행(data)의 저장값으로 초기화, 저장 시에만 부모에 반영 예정 */
const [refStatsCdLocal, setRefStatsCdLocal] = useState<string>('');
/** 수식 = 토큰 배열. 한글명 중복 시 출처(공통/대상/참조)별 engColNm 보존 */
const [formulaDisplaySegments, setFormulaDisplaySegments] = useState<string[]>([]);
const [formulaValueSegments, setFormulaValueSegments] = useState<string[]>([]);
/** 확인 클릭 시 검사 실패 메시지 (Textarea 아래 빨간 글씨) */
const [formulaSaveError, setFormulaSaveError] = useState<string | null>(null);
/** 참조 통계 변경 시 안내 메시지 */
const [refStatsChangeNotice, setRefStatsChangeNotice] = useState<string | null>(null);
const formulaTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const { setMessage: alert } = useAlertStore((state) => state);
/** 다이얼로그에서 쓰는 기호 → 제출용 value. 백엔드 규격에 맞게 수정 가능 */
const OPERATOR_SYMBOL_TO_VALUE: Record<string, string> = {
'+': '+',
'-': '-',
'×': '*',
'%': '/',
'(': '(',
')': ')',
};
/** 공통 → 대상 → 참조 순, 첫 등장 kornColNm만 사용 (로드/직접입력 시 파싱용) */
const getKornToEngMapForParse = (): Map<string, string> => {
const map = new Map<string, string>();
const refCols = selectedColumnInfo?.targetColumnInfo?.colList ?? columnInfo?.refColumnInfo?.colList ?? [];
[...(columnInfo?.commonColumnInfo ?? []), ...(columnInfo?.targetColumnInfo?.colList ?? []), ...refCols].forEach(
(item) => {
if (!map.has(item.kornColNm)) map.set(item.kornColNm, item.engColNm);
}
);
return map;
};
/** 한글 수식 문자열을 토큰(표시/값) 배열로 파싱. 컬럼명은 longest match */
const parseDisplayToSegments = (displayStr: string): { display: string[]; value: string[] } => {
if (displayStr.length === 0) return { display: [], value: [] };
const kornToEng = getKornToEngMapForParse();
const kornList = [...kornToEng.keys()].filter(Boolean).sort((a, b) => b.length - a.length);
const display: string[] = [];
const value: string[] = [];
let i = 0;
while (i < displayStr.length) {
let matched = false;
for (const korn of kornList) {
if (displayStr.slice(i, i + korn.length) === korn) {
display.push(korn);
value.push(kornToEng.get(korn) ?? korn);
i += korn.length;
matched = true;
break;
}
}
if (!matched) {
const ch = displayStr[i];
if (ch != null) {
display.push(ch);
value.push(ch);
}
i += 1;
}
}
return { display, value };
};
const handleFormulaKey = (val: string) => {
setFormulaSaveError(null);
if (val === 'backspace') {
setFormulaDisplaySegments((prev) => prev.slice(0, -1));
setFormulaValueSegments((prev) => prev.slice(0, -1));
} else if (val === 'ac') {
setFormulaDisplaySegments([]);
setFormulaValueSegments([]);
} else {
setFormulaDisplaySegments((prev) => [...prev, val]);
setFormulaValueSegments((prev) => [...prev, val]);
}
};
/** 컬럼 버튼: 한글 표시 + 해당 출처의 영문명 저장 (한글 중복 시에도 구분) */
const handleFormulaColumn = (kornColNm: string, engColNm: string) => {
setFormulaSaveError(null);
setFormulaDisplaySegments((prev) => [...prev, kornColNm]);
setFormulaValueSegments((prev) => [...prev, engColNm]);
};
/** 제출/저장 시 사용할 영문 수식 (컬럼 engColNm + 연산자 기호 → value 치환) */
const getFormulaValueForSubmit = (): string =>
formulaValueSegments.map((seg) => OPERATOR_SYMBOL_TO_VALUE[seg] ?? seg).join('');
const OPERATORS = ['+', '-', '×', '/', '%'] as const;
const isOperator = (s: string) => s.length === 1 && (OPERATORS as readonly string[]).includes(s);
const isParen = (s: string) => s === '(' || s === ')';
const isValueToken = (s: string) =>
s.length > 1 || (s.length === 1 && !isOperator(s) && !isParen(s));
const isDigit = (s: string) => s.length === 1 && /[0-9]/.test(s);
/** 저장 시 수식 규칙 검사. 오류 시 메시지 반환, 통과 시 null */
const validateFormulaForSave = (segments: string[]): string | null => {
if (segments.length === 0) return null;
for (let i = 0; i < segments.length - 1; i++) {
const a = segments[i];
const b = segments[i + 1];
if (a != null && b != null && isValueToken(a) && isValueToken(b) && !(isDigit(a) && isDigit(b))) {
return '컬럼(또는 숫자) 사이에 더하기, 빼기, 나누기, 곱하기 기호가 있어야 합니다.';
}
}
// "(값)"도 하나의 값으로 취급: 값/닫는괄호 뒤에는 '(' 또는 값이 올 수 없고, 사이에 연산자가 있어야 함.
// 예: 컬럼(100), 값)(100) 같은 형태는 연산자 누락으로 간주
for (let i = 0; i < segments.length - 1; i++) {
const a = segments[i];
const b = segments[i + 1];
if (a == null || b == null) continue;
// 연속 숫자(예: 50)는 예외 허용
if (isDigit(a) && isDigit(b)) continue;
const aIsValueBoundary = isValueToken(a) || a === ')';
const bIsValueBoundary = b === '(' || isValueToken(b);
if (aIsValueBoundary && bIsValueBoundary) {
return '컬럼(또는 숫자) 사이에 더하기, 빼기, 나누기, 곱하기 기호가 있어야 합니다.';
}
}
const last = segments[segments.length - 1];
if (last && isOperator(last)) {
return '수식은 기호로 끝날 수 없습니다. (닫는 괄호 제외)';
}
const stack: boolean[] = [];
for (let i = 0; i < segments.length; i++) {
const s = segments[i];
if (s == null) continue;
if (s === '(') {
stack.push(false);
} else if (s === ')') {
if (stack.length === 0) return '닫는 괄호가 여는 괄호 없이 사용되었습니다.';
const hadValue = stack.pop();
if (!hadValue) return '괄호 안에 값이 있어야 합니다.';
if (stack.length > 0) stack[stack.length - 1] = true;
} else if (stack.length > 0 && isValueToken(s)) {
stack[stack.length - 1] = true;
}
}
if (stack.length > 0) return '여는 괄호에 대응하는 닫는 괄호가 없습니다.';
return null;
};
const getVerifyColumnList = async () => {
try {
if (!trgtStatsCd) return;
let response;
if (data?.vrfcDtlSn == null || data.vrfcDtlSn <= 0) {
response = await generalverificationApiService.getSelectedVerifyColumnList(trgtStatsCd)
} else {
response = await generalverificationApiService.getVerifyColumnList({
trgtStatsCd,
vrfcDtlSn: data.vrfcDtlSn,
refStatsCd: data.refStatsCd,
});
}
if (response?.status === 'success') {
setColumnInfo(response.data);
} else {
alert(response?.message || '데이터 조회 중 오류가 발생했습니다.');
}
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
}
};
const getSelectedVerifyColumnList = async (statsCd: string) => {
try {
const response = await generalverificationApiService.getSelectedVerifyColumnList(statsCd);
if (response?.status === 'success' && response.data) {
setSelectedColumnInfo({
targetColumnInfo: response.data.targetColumnInfo ?? { statsCd: statsCd, colList: [] },
commonColumnInfo: response.data.commonColumnInfo ?? [],
});
} else {
setSelectedColumnInfo(null);
}
} catch {
setSelectedColumnInfo(null);
}
};
useEffect(() => {
if (open) {
setColumnInfo(null);
setSelectedColumnInfo(null);
}
if (open) void getVerifyColumnList();
}, [open, trgtStatsCd, data?.vrfcDtlSn]);
useEffect(() => {
if (open) setRefStatsCdLocal(data?.refStatsCd ?? '');
}, [open, data?.refStatsCd, data?.vrfcDtlSn]);
useEffect(() => {
if (!open) {
setSelectedColumnInfo(null);
setRefStatsCdLocal('');
setFormulaDisplaySegments([]);
setFormulaValueSegments([]);
setFormulaSaveError(null);
setRefStatsChangeNotice(null);
}
}, [open]);
useEffect(() => {
if (open) {
const str = data?.kornArtopnEqtnCn ?? '';
const { display, value } = parseDisplayToSegments(str);
setFormulaDisplaySegments(display);
setFormulaValueSegments(value);
}
}, [open, data?.kornArtopnEqtnCn, data?.vrfcDtlSn]);
/**
* 재오픈 직후에는 columnInfo가 아직 없어서 parseDisplayToSegments가 컬럼명을 매칭 못하고
* 한글을 1글자씩 토큰화하는 경우가 있음.
* 컬럼 목록 로딩 후(= map이 채워진 후) "표시 문자열이 원본과 동일 + 값 토큰이 표시 토큰과 동일"한 케이스만 재파싱해서
* engColNm 치환이 반영되도록 보정한다.
*/
useEffect(() => {
if (!open) return;
const src = data?.kornArtopnEqtnCn ?? '';
if (!src) return;
const displayJoined = formulaDisplaySegments.join('');
const valueJoined = formulaValueSegments.join('');
// 아직 컬럼 치환이 안 된 상태(= value가 display와 동일)에서만 재파싱
if (displayJoined !== src) return;
if (valueJoined !== displayJoined) return;
// 컬럼 목록이 준비된 이후에만 의미가 있음
const hasColumns =
(columnInfo?.commonColumnInfo?.length ?? 0) > 0 ||
(columnInfo?.targetColumnInfo?.colList?.length ?? 0) > 0 ||
(columnInfo?.refColumnInfo?.colList?.length ?? 0) > 0 ||
(selectedColumnInfo?.targetColumnInfo?.colList?.length ?? 0) > 0;
if (!hasColumns) return;
const { display, value } = parseDisplayToSegments(src);
const nextDisplayJoined = display.join('');
const nextValueJoined = value.join('');
// 파싱 결과가 현재와 완전히 같으면 setState 반복으로 무한 렌더가 날 수 있으니 중단
if (nextDisplayJoined === displayJoined && nextValueJoined === valueJoined) return;
// 컬럼 치환이 전혀 안 됐다면(= value가 display와 동일) 보정의 의미가 없으니 중단
if (nextValueJoined === nextDisplayJoined) return;
setFormulaDisplaySegments(display);
setFormulaValueSegments(value);
}, [
open,
data?.kornArtopnEqtnCn,
columnInfo?.commonColumnInfo,
columnInfo?.targetColumnInfo?.colList,
columnInfo?.refColumnInfo?.colList,
selectedColumnInfo?.targetColumnInfo?.colList,
formulaDisplaySegments,
formulaValueSegments,
]);
useEffect(() => {
if (!open || columnInfo?.kornArtopnEqtnCn == null) return;
if (data?.kornArtopnEqtnCn !== columnInfo.kornArtopnEqtnCn) return;
const { display, value } = parseDisplayToSegments(columnInfo.kornArtopnEqtnCn);
setFormulaDisplaySegments(display);
setFormulaValueSegments(value);
}, [open, data?.kornArtopnEqtnCn, columnInfo?.kornArtopnEqtnCn]);
const handleChangeRefStatsCd = (next: string) => {
if (!open) return;
// 현재 refStatsCdLocal에 해당하는 참조 컬럼이 수식에 포함돼 있으면 변경을 막고 안내만 표시
const currentRefCols = refColList ?? [];
const currentRefEngSet = new Set(currentRefCols.map((c) => c.engColNm).filter(Boolean));
const hasCurrentRefToken =
currentRefEngSet.size > 0 && formulaValueSegments.some((seg) => currentRefEngSet.has(seg));
if (hasCurrentRefToken) {
setRefStatsChangeNotice('수식에 기존 참조 컬럼이 포함되어 있어 참조 통계를 변경할 수 없습니다. 먼저 해당 참조 컬럼을 제거해주세요.');
return;
}
setRefStatsChangeNotice(null);
setRefStatsCdLocal(next);
};
/** 참조 통계 선택 시 해당 통계의 컬럼 목록으로 교체 */
useEffect(() => {
if (!open) return;
if (refStatsCdLocal) {
void getSelectedVerifyColumnList(refStatsCdLocal);
} else {
setSelectedColumnInfo(null);
}
}, [open, refStatsCdLocal]);
/** 참조 컬럼: 선택한 참조 통계로 조회한 목록이 있으면 사용, 없으면 getVerifyColumnList 결과 사용 */
const refColList = selectedColumnInfo?.targetColumnInfo?.colList ?? columnInfo?.refColumnInfo?.colList ?? [];
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setFormulaDisplaySegments([]);
setFormulaValueSegments([]);
setFormulaSaveError(null);
setSelectedColumnInfo(null);
setRefStatsCdLocal('');
}
onOpenChange?.(nextOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent aria-describedby={undefined} className="max-w-[100rem] w-full max-h-[90%] overflow-auto">
<DialogHeader>
<DialogTitle>
수식 상세 정보
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex justify-start gap-20 p-4 border rounded-xl">
<div className="flex flex-col gap-2">
<h3 className="font-bold text-title text-[14px]">기호 및 숫자</h3>
<div className="grid grid-cols-3 gap-2 w-max">
<Button type="button" value="+" color="gray" className="w-[65px]" onClick={() => handleFormulaKey('+')}>
<span className="text-[18px]">+</span>
</Button>
<Button type="button" value="-" color="gray" className="w-[65px]" onClick={() => handleFormulaKey('-')}>
<span className="text-[18px]">-</span>
</Button>
<Button type="button" value="*" color="gray" className="w-[65px]" onClick={() => handleFormulaKey('×')}>
<span className="text-[18px]">×</span>
</Button>
<Button type="button" value="/" color="gray" className="w-[65px]" onClick={() => handleFormulaKey('%')}>
<span className="text-[18px]">%</span>
</Button>
<Button type="button" value="(" color="gray" className="w-[65px]" onClick={() => handleFormulaKey('(')}>
<span className="text-[18px]">(</span>
</Button>
<Button type="button" value=")" color="gray" className="w-[65px]" onClick={() => handleFormulaKey(')')}>
<span className="text-[18px]">)</span>
</Button>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 0, '⌫', 'AC'].map((n) => {
const keyVal = n !== 'AC' && n !== '⌫' ? String(n) : n === '⌫' ? 'backspace' : 'ac';
return (
<Button
key={n}
type="button"
value={keyVal}
color={n !== 'AC' && n !== '⌫' ? 'white' : 'primary'}
className="w-[65px]"
onClick={() => handleFormulaKey(keyVal)}
>
<span className="text-[18px]">{n}</span>
</Button>
);
})}
</div>
</div>
<div className="w-full flex flex-col gap-2">
<h3 className="font-bold text-title text-[14px]">수식 미리보기</h3>
<Textarea
className="w-full flex-1 bg-gray-100 resize-none !text-3xl p-4 !leading-loose text-gray-600 font-bold"
id="formulaDisplayInput"
ref={formulaTextareaRef}
value={formulaDisplaySegments.join('')}
onChange={(e) => {
setFormulaSaveError(null);
const { display, value } = parseDisplayToSegments(e.target.value);
setFormulaDisplaySegments(display);
setFormulaValueSegments(value);
}}
rows={4}
readOnly
/>
{formulaSaveError && (
<p className="text-sm text-red-600 mt-1" role="alert">
{formulaSaveError}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-2 p-4 border rounded-xl">
<div className="flex items-center justify-between">
<h3 className="font-bold text-title text-[14px]">공통 컬럼</h3>
<Button
type="button"
size="sm"
color="white"
onClick={() => setShowCommonColumns((prev) => !prev)}
>
{showCommonColumns ? "접기" : "펼치기"}
</Button>
</div>
{showCommonColumns ? (
<div className="flex flex-wrap gap-2">
{columnInfo?.commonColumnInfo?.length && columnInfo.commonColumnInfo.length > 0 ?
columnInfo.commonColumnInfo.map((c) => (
<Button
key={c.engColNm}
type="button"
value={c.engColNm}
size="sm"
color="lightGray"
onClick={() => handleFormulaColumn(c.kornColNm, c.engColNm)}
>
{c.kornColNm}
</Button>
))
:
<Skeleton className="h-[12rem] w-full rounded-xl" />
}
</div>
) : null}
</div>
<div className="flex flex-col gap-2 p-4 border rounded-xl">
<div className="flex items-center justify-between">
<h3 className="font-bold text-title text-[14px]">{trgtStatsNm} 컬럼</h3>
<Button
type="button"
size="sm"
color="white"
onClick={() => setShowTargetColumns((prev) => !prev)}
>
{showTargetColumns ? "접기" : "펼치기"}
</Button>
</div>
{showTargetColumns ? (
<div className="flex flex-wrap gap-2">
{columnInfo?.targetColumnInfo?.colList.length && columnInfo.targetColumnInfo.colList.length > 0 ?
columnInfo.targetColumnInfo.colList.map((t) => (
<Button
key={t.engColNm}
type="button"
value={t.engColNm}
size="sm"
onClick={() => handleFormulaColumn(t.kornColNm, t.engColNm)}
>
{t.kornColNm}
</Button>
))
:
<Skeleton className="h-[12rem] w-full rounded-xl" />
}
</div>
) : null}
</div>
<div className="flex flex-col gap-2 p-4 border rounded-xl">
<div className="flex items-center justify-between">
<div className="flex gap-2 justify-center items-center">
<h3 className="font-bold text-title text-[14px]">참조 컬럼</h3>
<CodeSelect
upCd="STITEM"
value={refStatsCdLocal}
onValueChange={handleChangeRefStatsCd}
placeholder="선택"
className="w-[16rem]"
/>
</div>
{(refColList?.length ?? 0) > 0 ?
<Button
type="button"
size="sm"
color="white"
onClick={() => setShowRefColumns((prev) => !prev)}
>
{showRefColumns ? "접기" : "펼치기"}
</Button>
:
null
}
</div>
{refStatsChangeNotice && (
<p className="text-[12px] text-red-600" role="alert">
{refStatsChangeNotice}
</p>
)}
{showRefColumns ? (
<div className="flex flex-wrap gap-2">
{(refColList?.length ?? 0) > 0 ?
refColList.map((r) => (
<Button
key={r.engColNm}
type="button"
value={r.engColNm}
size="sm"
color="sky"
onClick={() => handleFormulaColumn(r.kornColNm, r.engColNm)}
>
{r.kornColNm}
</Button>
))
:
null
}
</div>
) : null}
</div>
</div>
<DialogFooter>
<Button
size="lg"
type="button"
onClick={() => {
const err = validateFormulaForSave(formulaValueSegments);
if (err) {
setFormulaSaveError(err);
requestAnimationFrame(() => {
formulaTextareaRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
formulaTextareaRef.current?.focus?.();
});
return;
}
setFormulaSaveError(null);
const kornArtopnEqtnCn = formulaDisplaySegments.join('');
const engArtopnEqtnCn = getFormulaValueForSubmit();
const isEmptyFormula = kornArtopnEqtnCn.trim().length === 0;
onConfirm?.({
kornArtopnEqtnCn,
engArtopnEqtnCn,
refStatsCd: isEmptyFormula ? null : (refStatsCdLocal ? refStatsCdLocal : null),
});
onOpenChange?.(false);
}}
>
확인
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default FomulaDialog;
@@ -0,0 +1,41 @@
import { Button, Input } from "@common/components";;
import OperatorsComponents from "./OperatorsComponents";
import type { VerifyDetailResponseDto } from "@/entities/statistics/model/general-verification-interface";
interface FormulaComponentsProps {
data?: VerifyDetailResponseDto | null;
index?: number;
onDelete?: () => void;
onOperatorChange?: (value: string) => void;
handleFomulaDialog?: (show: boolean, data?: VerifyDetailResponseDto | null, index?: number) => void;
}
const FormulaComponents = ({ data, index, onDelete, onOperatorChange, handleFomulaDialog }: FormulaComponentsProps) => {
const cmprOpert = data?.cmprOpert;
return (
<div className='flex items-center gap-x-4 mb-2 p-2 justify-around rounded has-[.formulaDeleteBtn:hover]:bg-gray-100'>
<Button
type="button"
size="icon"
className="size-[3rem] shrink-0 rounded-full formulaDeleteBtn"
color='red'
onClick={onDelete}
>
<i className="ri-subtract-fill text-[16px]"></i>
</Button>
<Input
className='h-[4rem] !cursor-pointer'
placeholder='클릭하여 수식을 등록해주세요.'
id='formulaInput'
value={data ? data.kornArtopnEqtnCn : ''}
title={data ? data.kornArtopnEqtnCn : ''}
onClick={() => handleFomulaDialog?.(true, data, index)}
readOnly
/>
<OperatorsComponents cmprOpert={cmprOpert} onValueChange={onOperatorChange} />
</div>
);
};
export default FormulaComponents;
@@ -0,0 +1,76 @@
import { useEffect, useState } from "react";
import {
Select,
SelectTrigger,
SelectContent,
SelectValue,
SelectItem,
} from "@common/components";
import { generalverificationApiService } from '@/entities/statistics/api/general-verification-service';
type OperatorItem = {
opertSn: string;
opertSymbl: string;
opertKornNm: string;
};
interface OperatorsComponentsProps {
cmprOpert?: string | null;
onValueChange?: (value: string) => void;
placeholder?: string;
}
const OperatorsComponents = ({
cmprOpert,
onValueChange,
placeholder = "연산자 없음",
}: OperatorsComponentsProps) => {
const [operators, setOperators] = useState<OperatorItem[]>([]);
const [localValue, setLocalValue] = useState<string>(cmprOpert ?? "");
useEffect(() => {
setLocalValue(cmprOpert ?? "");
}, [cmprOpert]);
useEffect(() => {
const fetchOperators = async () => {
const response = await generalverificationApiService.getVrfcOperators();
if (response.status === 'success' && response.data) {
setOperators(response.data);
}
};
fetchOperators();
}, []);
// onValueChange가 있으면 부모가 제어(controlled), 없으면 내부 state로 표시
const isControlled = onValueChange !== undefined;
const value = (isControlled ? (cmprOpert ?? "") : localValue) || "";
return (
<Select
value={value}
onValueChange={(v) => {
setLocalValue(v);
onValueChange?.(v);
}}
>
<SelectTrigger className="!w-[150px] min-w-[150px] max-w-[150px] h-[4rem] overflow-hidden focus:outline-none focus:ring-0">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent className="w-[150px]">
<SelectItem value='none'>
연산자 없음
</SelectItem>
{operators.map((op) => (
<SelectItem key={op.opertSn} value={op.opertSymbl}>
{op.opertKornNm}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
export default OperatorsComponents;
@@ -46,7 +46,7 @@ interface RegistDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children?: React.ReactNode;
vrfcSn?: string;
vrfcSn?: number;
/** 수정/등록 성공 시 부모에서 목록 재조회 등 실행 */
onSuccess?: () => void;
}
@@ -61,7 +61,7 @@ const RegistDialog = memo(
...props
}: RegistDialogProps) => {
const isVrfcSn = vrfcSn ? true : false;
const isVrfcSn = vrfcSn != null;
const [checkVrfcNm, setCheckVrfcNm] = useState<boolean>(false);
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
@@ -69,13 +69,13 @@ const RegistDialog = memo(
const { data: detailData } = useQuery({
queryKey: [`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/masters`, vrfcSn],
queryFn: () => generalverificationApiService.getGeneralverificationInfo(vrfcSn!),
enabled: open && !!vrfcSn,
enabled: open && vrfcSn != null,
});
const form = useForm<GeneralVerificationMasterSubmitFormData>({
resolver: zodResolver(generalVerificationMasterSubmitSchema),
values: {
vrfcSn: detailData?.data.vrfcSn ?? '',
vrfcSn: detailData?.data.vrfcSn ?? undefined,
vrfcNm: detailData?.data.vrfcNm ?? '',
vrfcClsfCd: detailData?.data.vrfcClsfCd ?? '',
trgtStatsCd: detailData?.data.trgtStatsCd ?? '',
@@ -177,8 +177,8 @@ const RegistDialog = memo(
if (await confirm(deleteMsg)) {
try {
const response = await generalverificationApiService.DeleteVrfcMaster(vrfcSn || '');
const response = await generalverificationApiService.DeleteVrfcMaster(vrfcSn!);
if (response.status === 'success') {
alert('삭제되었습니다.');
onOpenChange(false);
@@ -588,13 +588,13 @@ const RegistDialog = memo(
</TableBody>
</Table>
<DialogFooter>
{vrfcSn &&
{vrfcSn != null &&
<Button size="lg" type="button" color="red" onClick={handleDeleteVrfcMaster}>
삭제
</Button>
}
<Button size="lg" type="submit">
{vrfcSn ? '수정' : '등록'}
{vrfcSn != null ? '수정' : '등록'}
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
@@ -5,6 +5,7 @@ import type { GeneralverificationList } from "@/entities/statistics/model/genera
import { Button } from "@common/components";
import RegistDialog from "@/components/statistics/verification/generalVerification/RegistDialog";
import CopyDialog from "@/components/statistics/verification/generalVerification/CopyDialog";
export const getGeneralVerificationColumns = (
onUpdateSuccess?: () => void,
@@ -18,10 +19,6 @@ export const getGeneralVerificationColumns = (
return totalRows - (pageIndex * pageSize + row.index);
},
},
// {
// accessorKey: "vrfcSn",
// header: "일련번호",
// },
{
accessorKey: "trgtStatsNm",
header: "통계항목",
@@ -46,6 +43,18 @@ export const getGeneralVerificationColumns = (
return date.format("YYYY-MM-DD");
},
},
{
id: "copy",
header: "복사",
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<GeneralVerificationCopyButton
row={row.original}
onSuccess={onUpdateSuccess}
/>
</div>
),
},
{
id: "actions",
header: "관리",
@@ -64,7 +73,7 @@ const GeneralVerificationManageButton = ({
vrfcSn,
onSuccess,
}: {
vrfcSn: string;
vrfcSn: number;
onSuccess?: () => void;
}) => {
const [dialogOpen, setDialogOpen] = useState(false);
@@ -79,3 +88,23 @@ const GeneralVerificationManageButton = ({
</RegistDialog>
);
};
const GeneralVerificationCopyButton = ({
row,
onSuccess,
}: {
row: GeneralverificationList;
onSuccess?: () => void;
}) => {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<CopyDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
row={row}
onSuccess={onSuccess}
>
<Button size="sm" color="white">복사</Button>
</CopyDialog>
);
};
@@ -13,6 +13,13 @@ import type {
RegistVrfcMasterRequest,
RegistVrfcMasterResponse,
DeleteVrfcMasterResponse,
VrfcOperatorsResponse,
GeneralverificationDetailResponse,
VerifyColumnListRequest,
VerifyColumnListResponse,
VerifyDetailRegistRequest,
VerifyDetailUpdateRequest,
CopyVerifyDataRequest,
} from "../model/general-verification-interface";
export class GeneralverificationApiService implements GeneralverificationService {
@@ -24,7 +31,7 @@ export class GeneralverificationApiService implements GeneralverificationService
return response.data;
}
public async getGeneralverificationInfo(param: string): Promise<GeneralverificationInfoResponse> {
public async getGeneralverificationInfo(param: number): Promise<GeneralverificationInfoResponse> {
const response = await stClient.get<GeneralverificationInfoResponse> (
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/masters/${param}`,
);
@@ -55,12 +62,66 @@ export class GeneralverificationApiService implements GeneralverificationService
return response.data;
}
public async DeleteVrfcMaster(param: string): Promise<DeleteVrfcMasterResponse> {
public async DeleteVrfcMaster(param: number): Promise<DeleteVrfcMasterResponse> {
const response = await stClient.delete<DeleteVrfcMasterResponse> (
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param}`,
);
return response.data;
}
public async getVrfcOperators(): Promise<VrfcOperatorsResponse> {
const response = await stClient.get<VrfcOperatorsResponse> (
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/operators`,
);
return response.data;
}
public async getVrfcDetail(param: number): Promise<GeneralverificationDetailResponse> {
const response = await stClient.get<GeneralverificationDetailResponse> (
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param}`,
);
return response.data;
}
public async getVerifyColumnList(param: VerifyColumnListRequest): Promise<VerifyColumnListResponse> {
const response = await stClient.get<VerifyColumnListResponse> (
param.refStatsCd ?
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param.trgtStatsCd}/${param.vrfcDtlSn}/?refStatsCd=${param.refStatsCd}`
:
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param.trgtStatsCd}/${param.vrfcDtlSn}`,
);
return response.data;
}
public async getSelectedVerifyColumnList(param: string): Promise<VerifyColumnListResponse> {
const response = await stClient.get<VerifyColumnListResponse> (
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param}`,
);
return response.data;
}
public async RegistVrfcDetail(param: VerifyDetailRegistRequest): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse> (
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param.vrfcSn}/details`,
param
);
return response.data;
}
public async UpdateVrfcDetail(param: VerifyDetailUpdateRequest): Promise<RegistVrfcMasterResponse> {
const response = await stClient.put<RegistVrfcMasterResponse> (
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param.vrfcSn}/details`,
param
);
return response.data;
}
public async CopyVrfcData(param: CopyVerifyDataRequest): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse> (
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param.vrfcSn}/copy?targetStatsCd=${param.targetStatsCd}&rgtrId=${param.rgtrId}`,
);
return response.data;
}
}
export const generalverificationApiService = new GeneralverificationApiService();
@@ -1,5 +1,5 @@
export interface GeneralverificationList {
vrfcSn: string;
vrfcSn: number;
vrfcNm: string;
vrfcClsfCd: string;
vrfcClsfNm: string;
@@ -46,7 +46,7 @@ export interface CheckVerificationNameResponse {
}
export interface UpdateVrfcMasterRequest {
vrfcSn?: string | null;
vrfcSn?: number | null;
vrfcNm?: string | null;
vrfcClsfCd?: string | null;
dcptDigtCnt?: number | null;
@@ -79,11 +79,134 @@ export interface DeleteVrfcMasterResponse {
message?: string;
}
export interface VrfcOperatorsResponse {
status: string;
message?: string;
data?: {
opertSn: string;
opertSymbl: string;
opertKornNm: string;
}[]
}
/** 검증 상세 - 공통 정보 */
export interface GeneralverificationDetailCommonInfo {
vrfcSn: number;
vrfcNm: string;
vrfcClsfCd: string;
vrfcClsfNm: string;
trgtStatsCd: string;
trgtStatsNm: string;
dcptDigtCnt: number;
dcptPrcsMthCd: string | null;
dcptPrcsMthNm: string | null;
useYn: string;
}
/** 검증 상세 - 수식/조건 항목 */
export interface VerifyDetailResponseDto {
/** DB에서 내려온 경우에만 존재(신규 작성 중이면 null) */
vrfcDtlSn: number | null;
refStatsCd: string | null;
refStatsNm: string | null;
kornArtopnEqtnCn: string;
engArtopnEqtnCn: string;
cmprOpert: string | null;
}
export interface VerifyDetailData {
commonInfo: GeneralverificationDetailCommonInfo;
verifyDetailResponseDtoList: VerifyDetailResponseDto[];
}
export interface GeneralverificationDetailResponse {
status: string;
message?: string;
data: VerifyDetailData;
}
export interface VerifyColumnListRequest {
trgtStatsCd: string;
vrfcDtlSn: number;
refStatsCd?: string | null;
}
export interface VerifyColumnItem {
kornColNm: string;
engColNm: string;
}
/** 통계코드 + 컬럼 목록 */
export interface VerifyColumnInfo {
statsCd: string;
colList: VerifyColumnItem[];
}
export interface VerifyColumnListResponseData {
vrfcDtlSn?: number;
kornArtopnEqtnCn?: string;
engArtopnEqtnCn?: string;
targetColumnInfo?: VerifyColumnInfo;
commonColumnInfo?: VerifyColumnItem[];
refColumnInfo?: VerifyColumnInfo;
}
export interface VerifyColumnListResponse {
status: string;
message?: string;
data: VerifyColumnListResponseData;
}
export interface SelectedVerifyColumnListResponseData {
targetColumnInfo: VerifyColumnInfo;
commonColumnInfo: VerifyColumnItem[];
}
export interface SelectedVerifyColumnListResponse {
status: string;
message?: string;
data: VerifyColumnListResponseData
}
export interface VerifyDetailRegistData {
trgtStatsCd: string;
refStatsCd: string | null;
kornArtopnEqtnCn: string,
engArtopnEqtnCn: string,
cmprOpert: string | null;
eqtnCombSeq: number
}
export interface VerifyDetailRegistRequest {
vrfcSn: number | null;
detailList: VerifyDetailRegistData[]
rgtrId: string;
}
export interface VerifyDetailUpdateRequest {
vrfcSn: number | null;
detailList: VerifyDetailRegistData[]
mdfrId: string;
}
export interface CopyVerifyDataRequest {
vrfcSn?: number | null;
targetStatsCd : string;
rgtrId: string;
}
export interface GeneralverificationService {
getGeneralverificationList(param: GeneralverificationRequest): Promise<GeneralverificationResponse>;
getGeneralverificationInfo(param: string): Promise<GeneralverificationInfoResponse>;
getGeneralverificationInfo(param: number): Promise<GeneralverificationInfoResponse>;
checkVerificationName(param: CheckVerificationNameRequest): Promise<CheckVerificationNameResponse>;
UpdateVrfcMaster(param: UpdateVrfcMasterRequest): Promise<UpdateVrfcMasterResponse>;
RegistVrfcMaster(param: RegistVrfcMasterRequest): Promise<RegistVrfcMasterResponse>;
DeleteVrfcMaster(param: string): Promise<DeleteVrfcMasterResponse>;
DeleteVrfcMaster(param: number): Promise<DeleteVrfcMasterResponse>;
getVrfcOperators(): Promise<VrfcOperatorsResponse>;
getVrfcDetail(param: number): Promise<GeneralverificationDetailResponse>;
getVerifyColumnList(param: VerifyColumnListRequest): Promise<VerifyColumnListResponse>;
getSelectedVerifyColumnList(param: string): Promise<VerifyColumnListResponse>;
RegistVrfcDetail(param: VerifyDetailRegistRequest): Promise<RegistVrfcMasterResponse>;
UpdateVrfcDetail(param: VerifyDetailUpdateRequest): Promise<RegistVrfcMasterResponse>;
CopyVrfcData(param: CopyVerifyDataRequest): Promise<RegistVrfcMasterResponse>;
}
@@ -2,7 +2,7 @@ import { z } from "zod";
export const generalVerificationMasterSubmitSchema = z
.object({
vrfcSn: z.string().nullable().optional(),
vrfcSn: z.number().nullable().optional(),
vrfcNm: z.string().min(1, "검증명을 입력하세요"),
vrfcClsfCd: z.string().min(1, "검증분류를 선택하세요"),
trgtStatsCd: z.string().min(1, "통계항목을 선택하세요"),