[as frontend] 입력화면 콤포넌트화 처리

This commit is contained in:
2026-05-28 13:59:28 +09:00
parent a87f887635
commit 426e3ffcdf
6 changed files with 176 additions and 155 deletions
@@ -69,22 +69,9 @@ const num = (v: string) => parseFloat(v.replace(/,/g, "") || "0");
const fmt = (v: number) => (isNaN(v) ? "" : v.toLocaleString());
/* ------------------------------------------------------------------ */
/* 공통 셀 컴포넌트 (컴포넌트 외부에 선언해야 re-mount 방지) */
/* 공통 셀 컴포넌트 분리: widgets/cnfm/ui/cells.tsx에서 import */
/* ------------------------------------------------------------------ */
const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<td>
<input
type="text"
className="cnfm-input w-full min-w-0 text-right"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</td>
);
const ReadCell = ({ value }: { value: string | number }) => (
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">{value}</td>
);
import { NumCell, ReadCell, PrivacyConsent, EtcAttachments } from '@/widgets/cnfm';
/* ------------------------------------------------------------------ */
/* 메인 컴포넌트 */
@@ -112,7 +99,6 @@ function CnfmRegistPage() {
sessionStorage.removeItem(INSTL_SNCN_REGISTER_SELECTION_KEY);
}
}, []);
/* ── 기본 정보 ── */
const [bzNm, setBzNm] = useState("");
const [bzBgngYr, setBzBgngYr] = useState("");
@@ -129,23 +115,6 @@ function CnfmRegistPage() {
/* ── 개인정보 동의 ── */
const [privacyAgree, setPrivacyAgree] = useState<"Y" | "N">("N");
/* ── 기타 첨부파일 ── */
type EtcFile = {
id: string;
name: string;
ext: string;
size: string;
uploader: string;
atflCd: string;
atflId: string;
};
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
const [etcFileGroupId, setEtcFileGroupId] = useState("");
const [etcUploading, setEtcUploading] = useState(false);
const etcFileGroupIdRef = useRef("");
/* ── 필수 첨부파일 ── */
const { data: requiredAttachmentDefs = [] } = useQuery<EsntlAtchFileMngTreeResponseDto[]>({
queryKey: ["esntlAtchFileTree", docSeComCd],
queryFn: () => esntlAtchFileMngService.findTree({ docSeComCd }),
@@ -237,8 +206,24 @@ function CnfmRegistPage() {
upload.start();
};
/** 기타첨부파일 삭제 핸들러: 백엔드에 DELETE 요청을 보내 소프트삭제 처리 후 상태 갱신 */
const handleDeleteEtcFile = async (fileId: string, atflId: string) => {
/* ── 기타 첨부파일 ── */
type EtcFile = {
id: string;
name: string;
ext: string;
size: string;
uploader: string;
atflCd: string;
atflId: string;
};
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
const [etcFileGroupId, setEtcFileGroupId] = useState("");
const [etcUploading, setEtcUploading] = useState(false);
const etcFileGroupIdRef = useRef("");
/** 기타첨부파일 삭제 핸들러: 백엔드에 DELETE 요청을 보내 소프트삭제 처리 후 상태 갱신 */
const handleDeleteEtcFile = async (fileId: string, atflId: string) => {
try {
if (atflId) {
await deleteAttachment(atflId);
@@ -932,61 +917,35 @@ function CnfmRegistPage() {
</table>
</div>
{/* ── 개인정보 동의 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">개인정보 동의</h3>
<div className="mb-3 rounded border bg-gray-50 p-3 text-[1.25rem] leading-relaxed text-gray-600 max-h-80 overflow-auto whitespace-pre-wrap pr-2">
<p className="mb-1 font-semibold">[ 개인정보 수집항목 ]</p>
<div>
* 필수항목 : 소속기관, 소속부서, 담당자, 직급, 사무실 번호, FAX 번호<br />
․ 선택항목 : 휴대폰 번호, 이메일 주소<br />
․ 기타항목 : IP Address, 쿠키, 시스템 접속 및 이용 기록<br />
※ 시스템 이용과정에서 기타 항목의 정보들이 자동으로 생성되어 수집될 수 있습니다.
</div>
<p className="mt-4 font-semibold">[ 개인정보 이용목적 ]</p>
<div>
․ 국가하수도정보시스템 웹서비스 이용에 따른 본인 확인 절차에 이용<br />
․ 국가하수도정보시스템 사용에 필요한 권한 부여<br />
․ 사용자별 맞춤 컨텐츠 (처리시설의 운영 데이터 조회, 수질현황 데이터 조회, 예산 편성 / 예산편성 등) 제공에 이용
</div>
<div className="tit mt-4">● 개인정보의 보유 및 이용기간</div>
<div>
․ 수집된 고객의 개인정보는 2년 동안 보유하며 그 이후에는 문서를 파쇄합니다.<br />
․ 정보제공자가 개인정보 파쇄를 요청할 경우 즉시 파쇄합니다.
</div>
<br />
<div className="tit">● 개인정보 제공 거부에 따른 제한사항</div>
<div>
․ 귀하는 개인정보 제공 동의를 거부할 권리가 있으나, 동의 거부 시 국가하수도정보시스템 이용 서비스에 제한이 있을 수 있습니다.
</div>
{/* ── 개인정보 동의 (위젯으로 분리) ── */}
<PrivacyConsent value={privacyAgree} onChange={setPrivacyAgree}>
<p className="mb-1 font-semibold">[ 개인정보 수집항목 ]</p>
<div>
* 필수항목 : 소속기관, 소속부서, 담당자, 직급, 사무실 번호, FAX 번호<br />
․ 선택항목 : 휴대폰 번호, 이메일 주소<br />
․ 기타항목 : IP Address, 쿠키, 시스템 접속 및 이용 기록<br />
※ 시스템 이용과정에서 기타 항목의 정보들이 자동으로 생성되어 수집될 수 있습니다.
</div>
<div className="flex gap-6 text-[1.3rem]">
<label className="flex cursor-pointer items-center gap-2 font-semibold">
<input
type="radio"
name="privacyAgree"
value="Y"
checked={privacyAgree === "Y"}
onChange={() => setPrivacyAgree("Y")}
/>
예 (동의)
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="privacyAgree"
value="N"
checked={privacyAgree === "N"}
onChange={() => setPrivacyAgree("N")}
/>
아니오 (미동의)
</label>
<p className="mt-4 font-semibold">[ 개인정보 이용목적 ]</p>
<div>
․ 국가하수도정보시스템 웹서비스 이용에 따른 본인 확인 절차에 이용<br />
․ 국가하수도정보시스템 사용에 필요한 권한 부여<br />
․ 사용자별 맞춤 컨텐츠 (처리시설의 운영 데이터 조회, 수질현황 데이터 조회, 예산 편성 / 예산편성 등) 제공에 이용
</div>
</div>
<div className="tit mt-4">● 개인정보의 보유 및 이용기간</div>
<div>
․ 수집된 고객의 개인정보는 2년 동안 보유하며 그 이후에는 문서를 파쇄합니다.<br />
․ 정보제공자가 개인정보 파쇄를 요청할 경우 즉시 파쇄합니다.
</div>
<br />
<div className="tit">● 개인정보 제공 거부에 따른 제한사항</div>
<div>
․ 귀하는 개인정보 제공 동의를 거부할 권리가 있으나, 동의 거부 시 국가하수도정보시스템 이용 서비스에 제한이 있을 수 있습니다.
</div>
</PrivacyConsent>
{/* ── 사업규모 ── */}
<div className="cnfm-card p-4">
@@ -1345,74 +1304,12 @@ function CnfmRegistPage() {
</button>
</div>
{/* 파일 업로드 영역 */}
<div className="mb-3 flex flex-nowrap items-center gap-2">
<input
type="text"
className="cnfm-input min-w-0 flex-1"
placeholder="첨부문서를 등록해주세요"
value={
etcUploading
? "업로드 중..."
: etcFiles.length > 0
? `${etcFiles.length}개 파일 등록됨`
: ""
}
readOnly
/>
<label className="cnfm-btn-search shrink-0 cursor-pointer whitespace-nowrap text-[1.2rem]">
{etcUploading ? "업로드중" : "첨부파일"}
<input
type="file"
className="hidden"
multiple
onChange={handleEtcFilesChange}
disabled={etcUploading}
/>
</label>
</div>
{/* 첨부된 파일 목록 테이블 */}
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th>파일명</th>
<th className="w-[8rem]">확장자</th>
<th className="w-[8rem]">크기</th>
<th className="w-[12rem]">등록자</th>
<th className="w-[6rem]">삭제</th>
</tr>
</thead>
<tbody>
{etcFiles.length === 0 ? (
<tr>
<td colSpan={5} className="cnfm-td-empty">
첨부된 파일이 없습니다.
</td>
</tr>
) : (
etcFiles.map((f) => (
<tr key={f.id}>
<td className="pl-2 text-left">{f.name}</td>
<td>{f.ext}</td>
<td>{f.size}</td>
<td>{f.uploader}</td>
<td>
<button
type="button"
className="text-[1.2rem] text-red-500 hover:text-red-700"
onClick={() => void handleDeleteEtcFile(f.id, f.atflId)}
>
✕
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<EtcAttachments
etcFiles={etcFiles}
etcUploading={etcUploading}
onFilesChange={handleEtcFilesChange}
onDelete={(fileId, atflId) => void handleDeleteEtcFile(fileId, atflId)}
/>
</div>
{/* ── 액션 버튼 ── */}
+1
View File
@@ -0,0 +1 @@
export * from './ui';
@@ -0,0 +1,71 @@
import React from "react";
type EtcFile = {
id: string;
name: string;
ext: string;
size: string;
uploader: string;
atflCd: string;
atflId: string;
};
type Props = {
etcFiles: EtcFile[];
etcUploading: boolean;
onFilesChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onDelete: (fileId: string, atflId: string) => void;
};
export function EtcAttachments({ etcFiles, etcUploading, onFilesChange, onDelete }: Props) {
return (
<div>
<div className="mb-3 flex flex-nowrap items-center gap-2">
<input
type="text"
className="cnfm-input min-w-0 flex-1"
placeholder="첨부문서를 등록해주세요"
value={
etcUploading ? "업로드 중..." : etcFiles.length > 0 ? `${etcFiles.length}개 파일 등록됨` : ""
}
readOnly
/>
<label className="cnfm-btn-search shrink-0 cursor-pointer whitespace-nowrap text-[1.2rem] flex items-center">
{etcUploading ? "업로드중" : "첨부파일"}
<input type="file" className="hidden" multiple onChange={onFilesChange} disabled={etcUploading} />
</label>
</div>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th>파일명</th>
<th>확장자</th>
<th>크기</th>
<th>등록자</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{etcFiles.map((f) => (
<tr key={f.id}>
<td className="pr-2">{f.name}</td>
<td className="text-center">{f.ext}</td>
<td className="text-right">{f.size}</td>
<td className="text-center">{f.uploader}</td>
<td className="text-center">
<button className="cnfm-btn-delete" onClick={() => onDelete(f.id, f.atflId)}>
삭제
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default EtcAttachments;
+18
View File
@@ -0,0 +1,18 @@
import React from "react";
export const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<td>
<input
type="text"
className="cnfm-input w-full min-w-0 text-right"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</td>
);
export const ReadCell = ({ value }: { value: string | number }) => (
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">{value}</td>
);
export default { NumCell, ReadCell };
+3
View File
@@ -0,0 +1,3 @@
export { NumCell, ReadCell } from './cells';
export { PrivacyConsent } from './privacy-consent';
export { EtcAttachments } from './attachments';
@@ -0,0 +1,31 @@
import React from "react";
type Props = {
value: "Y" | "N";
onChange: (v: "Y" | "N") => void;
children?: React.ReactNode;
};
export function PrivacyConsent({ value, onChange, children }: Props) {
return (
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">개인정보 동의</h3>
<div className="mb-3 rounded border bg-gray-50 p-3 text-[1.25rem] leading-relaxed text-gray-600 max-h-80 overflow-auto whitespace-pre-wrap pr-2">
{children}
</div>
<div className="flex gap-6 text-[1.3rem]">
<label className="flex cursor-pointer items-center gap-2 font-semibold">
<input type="radio" name="privacyAgree" value="Y" checked={value === "Y"} onChange={() => onChange("Y")} />
예 (동의)
</label>
<label className="flex cursor-pointer items-center gap-2">
<input type="radio" name="privacyAgree" value="N" checked={value === "N"} onChange={() => onChange("N")} />
아니오 (미동의)
</label>
</div>
</div>
);
}
export default PrivacyConsent;