Merge remote-tracking branch 'origin/iipbs/cs/dev'

This commit is contained in:
2026-04-13 15:47:01 +09:00
77 changed files with 3927 additions and 2564 deletions
+4 -1
View File
@@ -13,6 +13,7 @@
> ⚠️ **중요**: 모든 것을 feature로 만들 필요는 없습니다.
Feature로 만드는 것이 좋은 지표:
- **여러 페이지에서 재사용됨**: 예를 들어, 여러 에디터가 있고 모두 댓글 기능이 있다면, 댓글은 재사용 가능한 feature입니다
- **중요한 비즈니스 기능**: 신규 프로젝트 참여자가 Features 폴더를 보고 앱의 주요 기능을 빠르게 파악할 수 있어야 합니다
@@ -56,7 +57,7 @@ export function LoginForm() {
password: ''
});
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.SubmitEvent) => {
e.preventDefault();
await loginUser(credentials);
};
@@ -104,11 +105,13 @@ export type { LoginCredentials } from './model/types';
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- `shared` 레이어의 모듈
- `entities` 레이어의 모듈
- 같은 feature 슬라이스 내의 다른 세그먼트
❌ **금지된 의존성**:
- 다른 `features` 슬라이스 (feature 간 직접 의존 금지)
- `widgets` 레이어
- `pages` 레이어
+155 -75
View File
@@ -14,7 +14,9 @@ interface SignupModalProps {
}
function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
const [currentStep, setCurrentStep] = useState<'agreement' | 'signup'>('agreement');
const [currentStep, setCurrentStep] = useState<"agreement" | "signup">(
"agreement",
);
const [agreed, setAgreed] = useState(false);
const [formData, setFormData] = useState({
userId: "",
@@ -47,7 +49,7 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
// 모달이 닫힐 때 초기화
useEffect(() => {
if (!open) {
setCurrentStep('agreement');
setCurrentStep("agreement");
setAgreed(false);
setFormData({
userId: "",
@@ -81,17 +83,19 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
const handleNextStep = () => {
if (agreed) {
setCurrentStep('signup');
setCurrentStep("signup");
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
setFormData((prev) => ({ ...prev, [name]: value }));
// 에러 초기화
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: "" }));
setErrors((prev) => ({ ...prev, [name]: "" }));
}
};
@@ -102,20 +106,27 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
if (!formData.userId) newErrors.userId = "아이디를 입력해주세요.";
if (!formData.userName) newErrors.userName = "이름을 입력해주세요.";
if (!formData.password) newErrors.password = "비밀번호를 입력해주세요.";
if (!formData.confirmPassword) newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
if (!formData.confirmPassword)
newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 입력해주세요.";
if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요.";
if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요.";
// 비밀번호 확인
if (formData.password && formData.confirmPassword && formData.password !== formData.confirmPassword) {
if (
formData.password &&
formData.confirmPassword &&
formData.password !== formData.confirmPassword
) {
newErrors.confirmPassword = "비밀번호가 일치하지 않습니다.";
}
// 비밀번호 정규식 검증
const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/;
const passwordRegex =
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/;
if (formData.password && !passwordRegex.test(formData.password)) {
newErrors.password = "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.";
newErrors.password =
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.";
}
// 이메일 형식 검증
@@ -124,8 +135,10 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
}
// 중복 체크 검증
if (idAvailable === false) newErrors.userId = "이미 사용 중인 아이디입니다.";
if (emailAvailable === false) newErrors.email = "이미 사용 중인 이메일입니다.";
if (idAvailable === false)
newErrors.userId = "이미 사용 중인 아이디입니다.";
if (emailAvailable === false)
newErrors.email = "이미 사용 중인 이메일입니다.";
setErrors(newErrors);
const isValid = Object.keys(newErrors).length === 0;
@@ -135,7 +148,7 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
const checkUserIdDuplicate = async () => {
if (!formData.userId) {
setErrors(prev => ({ ...prev, userId: "아이디를 입력해주세요." }));
setErrors((prev) => ({ ...prev, userId: "아이디를 입력해주세요." }));
return;
}
@@ -147,19 +160,25 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
{ userId: formData.userId }
{ userId: formData.userId },
);
const exists = response.data;
setIdAvailable(!exists);
if (exists) {
setErrors(prev => ({ ...prev, userId: "이미 사용 중인 아이디입니다." }));
setErrors((prev) => ({
...prev,
userId: "이미 사용 중인 아이디입니다.",
}));
} else {
setErrors(prev => ({ ...prev, userId: "" }));
setErrors((prev) => ({ ...prev, userId: "" }));
}
} catch (error) {
console.error("아이디 중복 체크 오류:", error);
setErrors(prev => ({ ...prev, userId: "아이디 중복 체크 중 오류가 발생했습니다." }));
setErrors((prev) => ({
...prev,
userId: "아이디 중복 체크 중 오류가 발생했습니다.",
}));
} finally {
setIsCheckingId(false);
}
@@ -167,7 +186,7 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
const checkEmailDuplicate = async () => {
if (!formData.email) {
setErrors(prev => ({ ...prev, email: "이메일을 입력해주세요." }));
setErrors((prev) => ({ ...prev, email: "이메일을 입력해주세요." }));
return;
}
@@ -179,28 +198,34 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
{ email: formData.email }
{ email: formData.email },
);
const exists = response.data;
setEmailAvailable(!exists);
if (exists) {
setErrors(prev => ({ ...prev, email: "이미 사용 중인 이메일입니다." }));
setErrors((prev) => ({
...prev,
email: "이미 사용 중인 이메일입니다.",
}));
} else {
setErrors(prev => ({ ...prev, email: "" }));
setErrors((prev) => ({ ...prev, email: "" }));
}
} catch (error) {
console.error("이메일 중복 체크 오류:", error);
setErrors(prev => ({ ...prev, email: "이메일 중복 체크 중 오류가 발생했습니다." }));
setErrors((prev) => ({
...prev,
email: "이메일 중복 체크 중 오류가 발생했습니다.",
}));
} finally {
setIsCheckingEmail(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.SubmitEvent) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨");
if (!validateForm()) {
console.log("폼 검증 실패");
return;
@@ -230,19 +255,19 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
// SignupClient를 통해 익명사용자 회원가입 API 호출
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL
baseURL: BASE_URL,
});
const response = await signupClient.post(
`${ENDPOINT.USER_SERVICE.USERS}/anonymouse/users/join`,
signupData
signupData,
);
console.log("회원가입 성공:", response.data);
// 성공 시 모달 닫기 및 콜백 호출
onSuccess();
onClose();
// 폼 초기화
setFormData({
userId: "",
@@ -267,13 +292,12 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
} catch (error: unknown) {
console.error("회원가입 오류:", error);
if (error instanceof Error) {
setErrors(prev => ({ ...prev, submit: error.message }));
setErrors((prev) => ({ ...prev, submit: error.message }));
} else {
setErrors(prev => ({ ...prev, submit: "회원가입에 실패했습니다." }));
setErrors((prev) => ({ ...prev, submit: "회원가입에 실패했습니다." }));
}
} finally {
setIsLoading(false);
@@ -286,36 +310,52 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{currentStep === 'agreement' ? '개인정보제공동의' : '회원가입'}</h2>
<button className="close-button" onClick={onClose}>×</button>
<h2>
{currentStep === "agreement" ? "개인정보제공동의" : "회원가입"}
</h2>
<button className="close-button" onClick={onClose}>
×
</button>
</div>
{currentStep === 'agreement' ? (
{currentStep === "agreement" ? (
<div className="agreement-content">
<div className="agreement-text">
<h3>개인정보 수집 및 이용 동의</h3>
<p>회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.</p>
<h4>1. 수집하는 개인정보 항목</h4>
<ul>
<li>필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, 기관구분</li>
<li>선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, 상세기관코드, 사업자등록번호</li>
<li>
필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분,
기관구분
</li>
<li>
선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드,
상세기관코드, 사업자등록번호
</li>
</ul>
<h4>2. 개인정보의 수집 및 이용목적</h4>
<ul>
<li>회원가입 및 관리</li>
<li>서비스 제공 및 운영</li>
<li>고객상담 및 문의응답</li>
</ul>
<h4>3. 개인정보의 보유 및 이용기간</h4>
<p>회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)</p>
<p>
회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당
기간까지)
</p>
<h4>4. 동의 거부권 및 거부 시 불이익</h4>
<p>개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 시 회원가입이 제한됩니다.</p>
<p>
개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부
시 회원가입이 제한됩니다.
</p>
</div>
<div className="agreement-checkbox">
<label>
<input
@@ -326,13 +366,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
<span>개인정보 수집 및 이용에 동의합니다.</span>
</label>
</div>
<div className="agreement-actions">
<button
type="button"
onClick={onClose}
className="cancel-button"
>
<button type="button" onClick={onClose} className="cancel-button">
취소
</button>
<button
@@ -349,7 +385,7 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
<form onSubmit={handleSubmit} className="signup-form">
<div className="form-section">
<h3>기본 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="userId">아이디 *</label>
@@ -373,8 +409,14 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
{isCheckingId ? "확인중..." : "중복확인"}
</button>
</div>
{idAvailable === true && <span className="success-message">사용 가능한 아이디입니다.</span>}
{errors.userId && <span className="error-message">{errors.userId}</span>}
{idAvailable === true && (
<span className="success-message">
사용 가능한 아이디입니다.
</span>
)}
{errors.userId && (
<span className="error-message">{errors.userId}</span>
)}
</div>
</div>
@@ -390,7 +432,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
placeholder="이름을 입력하세요"
disabled={isLoading}
/>
{errors.userName && <span className="error-message">{errors.userName}</span>}
{errors.userName && (
<span className="error-message">{errors.userName}</span>
)}
</div>
</div>
@@ -416,8 +460,14 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
{isCheckingEmail ? "확인중..." : "중복확인"}
</button>
</div>
{emailAvailable === true && <span className="success-message">사용 가능한 이메일입니다.</span>}
{errors.email && <span className="error-message">{errors.email}</span>}
{emailAvailable === true && (
<span className="success-message">
사용 가능한 이메일입니다.
</span>
)}
{errors.email && (
<span className="error-message">{errors.email}</span>
)}
</div>
</div>
@@ -434,7 +484,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={20}
disabled={isLoading}
/>
{errors.password && <span className="error-message">{errors.password}</span>}
{errors.password && (
<span className="error-message">{errors.password}</span>
)}
</div>
</div>
@@ -451,7 +503,11 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={20}
disabled={isLoading}
/>
{errors.confirmPassword && <span className="error-message">{errors.confirmPassword}</span>}
{errors.confirmPassword && (
<span className="error-message">
{errors.confirmPassword}
</span>
)}
</div>
</div>
@@ -468,7 +524,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={10}
disabled={isLoading}
/>
{errors.pswdHnt && <span className="error-message">{errors.pswdHnt}</span>}
{errors.pswdHnt && (
<span className="error-message">{errors.pswdHnt}</span>
)}
</div>
</div>
@@ -485,14 +543,16 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={100}
disabled={isLoading}
/>
{errors.pswdAns && <span className="error-message">{errors.pswdAns}</span>}
{errors.pswdAns && (
<span className="error-message">{errors.pswdAns}</span>
)}
</div>
</div>
</div>
<div className="form-section">
<h3>연락처 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="mphn">휴대폰 번호</label>
@@ -506,7 +566,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={11}
disabled={isLoading}
/>
{errors.mphn && <span className="error-message">{errors.mphn}</span>}
{errors.mphn && (
<span className="error-message">{errors.mphn}</span>
)}
</div>
</div>
@@ -523,14 +585,16 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={11}
disabled={isLoading}
/>
{errors.telno && <span className="error-message">{errors.telno}</span>}
{errors.telno && (
<span className="error-message">{errors.telno}</span>
)}
</div>
</div>
</div>
<div className="form-section">
<h3>주소 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="zip">우편번호</label>
@@ -544,7 +608,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={5}
disabled={isLoading}
/>
{errors.zip && <span className="error-message">{errors.zip}</span>}
{errors.zip && (
<span className="error-message">{errors.zip}</span>
)}
</div>
</div>
@@ -561,7 +627,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={200}
disabled={isLoading}
/>
{errors.addr && <span className="error-message">{errors.addr}</span>}
{errors.addr && (
<span className="error-message">{errors.addr}</span>
)}
</div>
</div>
@@ -578,14 +646,16 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={200}
disabled={isLoading}
/>
{errors.daddr && <span className="error-message">{errors.daddr}</span>}
{errors.daddr && (
<span className="error-message">{errors.daddr}</span>
)}
</div>
</div>
</div>
<div className="form-section">
<h3>기관 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="laeSeCd">법인구분 *</label>
@@ -601,7 +671,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
<option value="02">민간기업</option>
<option value="03">개인</option>
</select>
{errors.laeSeCd && <span className="error-message">{errors.laeSeCd}</span>}
{errors.laeSeCd && (
<span className="error-message">{errors.laeSeCd}</span>
)}
</div>
</div>
@@ -621,7 +693,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
<option value="03">공공기관</option>
<option value="04">민간기업</option>
</select>
{errors.ogdpSeCd && <span className="error-message">{errors.ogdpSeCd}</span>}
{errors.ogdpSeCd && (
<span className="error-message">{errors.ogdpSeCd}</span>
)}
</div>
</div>
@@ -638,7 +712,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={10}
disabled={isLoading}
/>
{errors.instSeCd && <span className="error-message">{errors.instSeCd}</span>}
{errors.instSeCd && (
<span className="error-message">{errors.instSeCd}</span>
)}
</div>
</div>
@@ -655,21 +731,25 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={10}
disabled={isLoading}
/>
{errors.dtlInstCd && <span className="error-message">{errors.dtlInstCd}</span>}
{errors.dtlInstCd && (
<span className="error-message">{errors.dtlInstCd}</span>
)}
</div>
</div>
</div>
{errors.submit && (
<div className="form-row">
<div className="error-message submit-error">{errors.submit}</div>
<div className="error-message submit-error">
{errors.submit}
</div>
</div>
)}
<div className="form-actions">
<button
type="button"
onClick={() => setCurrentStep('agreement')}
onClick={() => setCurrentStep("agreement")}
className="back-button"
disabled={isLoading}
>
@@ -305,7 +305,7 @@ function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) {
}
};
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.SubmitEvent) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨 - handleSubmit");
console.log("현재 폼 데이터:", formData);
+51 -30
View File
@@ -26,7 +26,7 @@ function LoginPage() {
console.log("Modal states - OTP:", showOtpModal, "2FA:", showTwoFAModal);
}, [showOtpModal, showTwoFAModal]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError("");
@@ -43,44 +43,54 @@ function LoginPage() {
if (isOtpEnabled) {
// 2FA 로그인 - Proxy를 통해 처리 (2FA 전용 엔드포인트 사용)
console.log("Starting 2FA login...");
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN_2FA}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
password,
provider: 'password'
}),
});
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN_2FA}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId,
password,
provider: "password",
}),
},
);
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || '로그인 실패';
let errorMsg = errorData.message || "로그인 실패";
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
errorMsg = errorData.errors
.map((e: { message: string }) => e.message)
.join(", ");
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("2FA login response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
console.log(
"Response headers:",
Object.fromEntries(response.headers.entries()),
);
// 2FA 로그인 응답에는 tempToken과 registered2FA만 포함됨
if (!data.tempToken) {
throw new Error("Missing tempToken in 2FA login response");
}
// 임시 토큰을 localStorage에 저장 (OTP 인증용)
localStorage.setItem('tempToken', data.tempToken);
localStorage.setItem("tempToken", data.tempToken);
console.log("tempToken saved:", data.tempToken);
if (data.registered2FA) {
console.log("User has 2FA registered, showing OTP modal");
setShowOtpModal(true);
} else {
console.log("User doesn't have 2FA registered, showing 2FA setup modal");
console.log(
"User doesn't have 2FA registered, showing 2FA setup modal",
);
setShowTwoFAModal(true);
}
} else {
@@ -91,7 +101,7 @@ function LoginPage() {
}
} catch (error: unknown) {
console.error("Login error:", error);
// 에러 처리
if (error instanceof Error) {
setError(error.message);
@@ -218,25 +228,36 @@ function LoginPage() {
<label htmlFor="checkTwoFA">OTP 인증</label>
</div>
</div>
<button
type="submit"
className="login_btn fx"
disabled={isLoading}
>
<button type="submit" className="login_btn fx" disabled={isLoading}>
<span>{isLoading ? "로그인 중..." : "LOGIN"}</span>
</button>
{error && (
<div className="error-message" style={{ color: "red", textAlign: "center", marginTop: "10px" }}>
<div
className="error-message"
style={{ color: "red", textAlign: "center", marginTop: "10px" }}
>
{error}
</div>
)}
{successMessage && (
<div className="success-message" style={{ color: "green", textAlign: "center", marginTop: "10px" }}>
<div
className="success-message"
style={{
color: "green",
textAlign: "center",
marginTop: "10px",
}}
>
{successMessage}
</div>
)}
<div className="signup-link" style={{ textAlign: "center", marginTop: "15px" }}>
<span style={{ color: "#666", fontSize: "14px" }}>계정이 없으신가요? </span>
<div
className="signup-link"
style={{ textAlign: "center", marginTop: "15px" }}
>
<span style={{ color: "#666", fontSize: "14px" }}>
계정이 없으신가요?{" "}
</span>
<button
type="button"
onClick={() => setShowSignupModal(true)}
@@ -246,7 +267,7 @@ function LoginPage() {
color: "#3b82f6",
textDecoration: "underline",
cursor: "pointer",
fontSize: "14px"
fontSize: "14px",
}}
>
회원가입
@@ -282,8 +303,8 @@ function LoginPage() {
종합환경연구단지)
</span>
<span>
전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723,
3722
전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계)
032-590-3723, 3722
</span>
<span>팩스 :032-590-3729</span>
</p>
+1 -1
View File
@@ -102,7 +102,7 @@ function UsersPage() {
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -83,7 +83,7 @@ function FaqPage({ bbsId }: { bbsId: string }) {
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
@@ -6,7 +6,7 @@ import { LoginApi } from "@/entities/auth";
import "./login.css";
function LoginPage() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
@@ -33,7 +33,7 @@ function SearchPage() {
queryFn: () => SearchApi.searchPost({ keyword: keyword, siteId }),
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
setKeyword(formData.get("keyword") as string);
@@ -84,7 +84,8 @@ function SearchPage() {
<TabsList className="mx-auto">
<TabsTrigger value="all">
전체 (
{ (data?.contsList?.length ?? 0) + (data?.boardList?.totalElements ?? 0)}
{(data?.contsList?.length ?? 0) +
(data?.boardList?.totalElements ?? 0)}
건)
</TabsTrigger>
<TabsTrigger value="contents">
@@ -134,7 +134,7 @@ function PostPage({ bbsId }: { bbsId: string }) {
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -0,0 +1,3 @@
import { NtftalkHistoryPage } from "@/pages/ntftalk-history";
export default NtftalkHistoryPage;
+164
View File
@@ -0,0 +1,164 @@
# 화면설계서: 알림톡발송이력관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | -------------------------------------------- |
| 버전 | 1.0 |
| 프로세스명 | UI설계 |
| 화면명 | 알림톡발송이력관리 |
| 화면 유형 | [x] 조회 [ ] 입력 [ ] 삭제 [ ] 수정 [ ] 출력 |
| 메뉴 경로 | home > 시스템관리 > 알림톡발송이력관리 |
---
## 2. 화면 목적
알림톡 발송 이력을 **기간·내용·수신자·결과·시스템** 조건으로 검색하고, **발송 시스템·알림 내용·수신자·수신 시각·성공 여부**를 목록으로 확인한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌────────────────────────────────────────────────────────────────────┐
│ 페이지 제목: 알림톡발송이력관리 브레드크럼 (우측 상단) │
├────────────────────────────────────────────────────────────────────┤
│ [영역 A] 검색 필터 │
│ · 조회기간, 알림내용, 사용자, 결과, 시스템 │
│ · [조회], [초기화] │
├────────────────────────────────────────────────────────────────────┤
│ [영역 B] 알림톡 발송 이력 그리드 │
│ · 순번, 시스템, 알림내용, 수신자, 수신일, 결과 │
│ · 페이지네이션 │
└────────────────────────────────────────────────────────────────────┘
```
---
## 4. 영역 A: 검색 필터
### 4-1. 필드 정의
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | -------------------- | ---- | ------------- | ----------------------------------- | ------------------------------------------- |
| 조회기간 | InputDateRangePicker | N | 1개월 전~오늘 | `dateFrom`, `dateTo` (`yyyy-MM-dd`) | `src/shared/ui/input-date-range-picker.tsx` |
| 알림내용 | 텍스트 | N | 공백 | `content` | |
| 사용자 | 텍스트 | N | 공백 | `recipientNum` | |
| 결과 | 드롭다운 | N | 전체 | `successYn` | `SUCCESS` \| `FAIL` \| `ALL` |
| 시스템 | SiteSelect | N | 전체 | `system` | `src/entities/site/ui/site-select.tsx` |
### 4-2. 버튼
| 버튼명 | 동작 |
| ------ | ---------------------------------------------------------------- |
| 조회 | 현재 필터로 영역 B 목록을 재조회한다. 페이지는 1페이지로 초기화. |
| 초기화 | 검색 필드를 기본값으로 되돌린다. |
---
## 5. 영역 B: 알림톡 발송 이력 그리드
### 5-1. 그리드 컬럼
| 표시명 | Field | 비고 |
| -------- | -------------- | ------------------------------------------------------------------------- |
| 순번 | — | 클라이언트 표시용 인덱스(서버 필드 없음). 페이지네이션 시 오프셋 반영 |
| 시스템 | `siteSystem` | 응답 시 `templateCode` 앞 4자 기반 파생(행정지원·하수도통계·포털·기타) |
| 알림내용 | `content` | 긴 텍스트 말줄임 처리. 툴팁 적용하여 전체 텍스트 확인 가능. |
| 수신자 | `recipientNum` | 실데이터·마스킹 규칙 **TBD** |
| 수신일 | `dateRslt` | `YYYY-MM-DD HH:mm:ss` 형식 표시. |
| 결과 | `successYn` | UI 표시: `SUCCESS` → 성공, `FAIL` → 실패(백엔드는 `reportCode` 기반 파생) |
- 행 클릭 UX 없음.
---
## 6. API/DTO 명세
### 6-1. 공통
- 목록 응답: Spring `Page` 직렬화 형태 — 프론트 타입 `PageableList<NtftalkMmtLogHistory>` (`content`, `totalElements`, `totalPages`, `number`, `size` 등).
- 목록 요청 쿼리: `Pageable` 표준 파라미터 `page`, `size`, `sort` (`PageableParams`).
### 6-2. 알림톡 발송 이력 API
Base: `/ntftalk-service/api/v1/ata/mmt-log-history`
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
| ----------------- | ------ | ----------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------- |
| 목록 조회(페이징) | GET | `/ata/mmt-log-history` | Query: `AtaMmtLogHistorySearchDto` 필드 + `Pageable`(`page`,`size`,`sort`) | `Page<AtaMmtLogHistoryResponseDto>` → `NtftalkMmtLogHistoryPageList` | `getNtftalkMmtLogHistoryPage` |
| 상세 조회 | GET | `/ata/mmt-log-history/{mtPr}` | Path: `mtPr` | `AtaMmtLogHistoryResponseDto` → `NtftalkMmtLogHistory` | `getNtftalkMmtLogHistory` |
| 등록 | POST | `/ata/mmt-log-history` | Body: `AtaMmtLogHistorySaveRequestDto` → `NtftalkMmtLogHistorySavePayload` | `AtaMmtLogHistoryResponseDto` | `saveNtftalkMmtLogHistory` |
| 수정 | PUT | `/ata/mmt-log-history/{mtPr}` | Path: `mtPr`, Body: `AtaMmtLogHistoryUpdateRequestDto` → `NtftalkMmtLogHistoryUpdatePayload` | `AtaMmtLogHistoryResponseDto` | `updateNtftalkMmtLogHistory` |
| 삭제 | DELETE | `/ata/mmt-log-history/{mtPr}` | Path: `mtPr` | 없음(204) | `deleteNtftalkMmtLogHistory` |
#### 검색 Query ↔ TypeScript (`NtftalkMmtLogHistorySearchParams`)
| Query (Java 필드) | TS 필드 | 설명 |
| ----------------- | --------------- | ------------------------------------ |
| `subject` | `subject?` | 메시지 제목 |
| `content` | `content?` | 전송 메시지 내용 |
| `recipientNum` | `recipientNum?` | 수신자 전화번호 |
| `msgStatus` | `msgStatus?` | `1` 전송대기, `2` 결과대기, `3` 완료 |
| `reportCode` | `reportCode?` | 전송 결과 코드(`1000`: 성공) |
| `dateFrom` | `dateFrom?` | `yyyy-MM-dd` |
| `dateTo` | `dateTo?` | `yyyy-MM-dd` |
| `system` | `system?` | `ADSS` \| `CWMS` \| `PORT` \| `ALL` |
| `successYn` | `successYn?` | `SUCCESS` \| `FAIL` \| `ALL` |
#### 응답 행 ↔ TypeScript (`NtftalkMmtLogHistory`)
백엔드 `AtaMmtLogHistoryResponseDto`와 동일 필드명: `mtPr`, `mtRefkey`, `priority`, `dateClientReq`, `subject`, `content`, `callback`, `msgStatus`, `recipientNum`, `dateMtSent`, `dateRslt`, `dateMtReport`, `reportCode`, `countryCode`, `msgType`, `regDate`, `senderKey`, `templateCode`, `siteSystem`, `successYn`.
---
## 7. UI 구현 가이드
### 7-1. 기술 스택·원칙
- **UI**: `@common/components` 공통 컴포넌트
- **테이블**: `@tanstack/react-table` + `useDataTable`(`src/shared/lib/use-data-table.tsx`)
- **데이터**: `@tanstack/react-query` — 목록은 `getNtftalkMmtLogHistoryPage`, 무한 스크롤 vs 페이지 UI는 **TBD**
- **레이아웃**: App Router `app/(with-base-layout)/ntftalk-history/page.tsx`에서 페이지 UI 마운트
### 7-2. 모듈·파일 배치
| 구분 | 경로 |
| --------------- | ------------------------------------------------------------------------------------------------------------------- |
| 라우트 엔트리 | `apps/cs/app/(with-base-layout)/ntftalk-history/page.tsx` |
| 페이지 셸 | `apps/cs/src/pages/ntftalk-history/ui/ntftalk-history-page.tsx` |
| 컬럼 정의 | `apps/cs/src/pages/ntftalk-history/model/*-table-columns.tsx` |
| API·타입 | `apps/cs/src/entities/ntftalk/model/ntftalk-mmt-log-history-interface.ts`, `api/ntftalk-mmt-log-history-service.ts` |
| 엔드포인트 상수 | `apps/cs/src/shared/config/api.ts` — `ENDPOINT.NTFTALK_SERVICE.MMT_LOG_HISTORY` |
### 7-3. 영역별 UI 매핑
| 영역 | 화면 요소 | 컴포넌트·훅 | 구현 비고 |
| ---- | ------------ | ------------------------------------------------ | ---------------------------- |
| A | 검색 폼 | `Input`, `Label`, `Select`, `Button` | |
| B | 이력 그리드 | `useDataTable` + `ColumnDef`, `manualPagination` | |
| 공통 | 로딩·빈 목록 | `LoadingWithAnimation`, 빈 데이터 안내 | 기타 목록 페이지와 동일 수준 |
### 7-4. 데이터 흐름
```
BEGIN
// 최초 진입
CALL 알림톡발송이력목록조회(기본 검색 조건, page=0, size=10)
DISPLAY 알림톡 발송 이력 그리드 (영역 B)
ON [조회] 버튼 CLICK DO
CALL 알림톡발송이력목록조회(조회기간→dateFrom/dateTo, content, recipientNum, successYn, system, page=0)
DISPLAY 알림톡 발송 이력 그리드 (영역 B)
END ON
ON [초기화] 버튼 CLICK DO
RESET 검색 조건 → 기본값
END ON
END
```
+2
View File
@@ -1,6 +1,7 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import baseEsLintConfig from "../../eslint.config.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -11,6 +12,7 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
...baseEsLintConfig,
{
rules: {
"react-hooks/exhaustive-deps": "off",
@@ -0,0 +1,58 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
type NtftalkMmtLogHistory,
type NtftalkMmtLogHistoryPageList,
type NtftalkMmtLogHistorySavePayload,
type NtftalkMmtLogHistorySearchParams,
type NtftalkMmtLogHistoryService,
type NtftalkMmtLogHistoryUpdatePayload,
} from "../model/ntftalk-mmt-log-history-interface";
class NtftalkMmtLogHistoryApiService implements NtftalkMmtLogHistoryService {
async getNtftalkMmtLogHistoryPage(
params: NtftalkMmtLogHistorySearchParams,
): Promise<NtftalkMmtLogHistoryPageList> {
const response = await client.get<NtftalkMmtLogHistoryPageList>(
`${ENDPOINT.NTFTALK_SERVICE.MMT_LOG_HISTORY}`,
{ params },
);
return response.data;
}
async getNtftalkMmtLogHistory(mtPr: number): Promise<NtftalkMmtLogHistory> {
const response = await client.get<NtftalkMmtLogHistory>(
`${ENDPOINT.NTFTALK_SERVICE.MMT_LOG_HISTORY}/${mtPr}`,
);
return response.data;
}
async saveNtftalkMmtLogHistory(
payload: NtftalkMmtLogHistorySavePayload,
): Promise<NtftalkMmtLogHistory> {
const response = await client.post<NtftalkMmtLogHistory>(
`${ENDPOINT.NTFTALK_SERVICE.MMT_LOG_HISTORY}`,
payload,
);
return response.data;
}
async updateNtftalkMmtLogHistory(
mtPr: number,
payload: NtftalkMmtLogHistoryUpdatePayload,
): Promise<NtftalkMmtLogHistory> {
const response = await client.put<NtftalkMmtLogHistory>(
`${ENDPOINT.NTFTALK_SERVICE.MMT_LOG_HISTORY}/${mtPr}`,
payload,
);
return response.data;
}
async deleteNtftalkMmtLogHistory(mtPr: number): Promise<void> {
await client.delete(
`${ENDPOINT.NTFTALK_SERVICE.MMT_LOG_HISTORY}/${mtPr}`,
);
}
}
export const NtftalkMmtLogHistoryApi = new NtftalkMmtLogHistoryApiService();
+8
View File
@@ -1,6 +1,7 @@
export { NtftalkVarApi } from "./api/ntftalk-var-service";
export { NtftalkTempApi } from "./api/ntftalk-temp-service";
export { NtftalkBiztalkApi } from "./api/ntftalk-biztalk-service";
export { NtftalkMmtLogHistoryApi } from "./api/ntftalk-mmt-log-history-service";
export type {
NtftalkVar,
NtftalkVarPageList,
@@ -27,3 +28,10 @@ export type {
NtftalkTempSearchParams,
NtftalkTempUpdatePayload,
} from "./model/ntftalk-temp-interface";
export type {
NtftalkMmtLogHistory,
NtftalkMmtLogHistoryPageList,
NtftalkMmtLogHistorySavePayload,
NtftalkMmtLogHistorySearchParams,
NtftalkMmtLogHistoryUpdatePayload,
} from "./model/ntftalk-mmt-log-history-interface";
@@ -0,0 +1,84 @@
import type { PageableList, PageableParams } from "@/shared/model";
export interface NtftalkMmtLogHistorySearchParams extends PageableParams {
subject?: string;
content?: string;
recipientNum?: string;
/** 1:전송대기, 2:결과대기, 3:완료 */
msgStatus?: string;
/** 1000:성공 */
reportCode?: string;
/** yyyy-MM-dd */
dateFrom?: string;
/** yyyy-MM-dd */
dateTo?: string;
/** ADSS | CWMS | PORT | ALL */
system?: string;
/** SUCCESS | FAIL | ALL */
successYn?: string;
}
export interface NtftalkMmtLogHistory {
mtPr: number;
mtRefkey?: string | null;
priority?: string | null;
dateClientReq?: string | null;
subject?: string | null;
content?: string | null;
callback?: string | null;
msgStatus?: string | null;
recipientNum?: string | null;
dateMtSent?: string | null;
dateRslt?: string | null;
dateMtReport?: string | null;
reportCode?: string | null;
countryCode?: string | null;
msgType?: number | null;
regDate?: string | null;
senderKey?: string | null;
templateCode?: string | null;
siteSystem?: string | null;
successYn?: string | null;
}
export interface NtftalkMmtLogHistorySavePayload {
mtPr: number;
mtRefkey?: string;
priority?: string;
dateClientReq?: string;
subject: string;
content: string;
callback: string;
msgStatus?: string;
recipientNum: string;
reportCode?: string;
countryCode?: string;
msgType?: number;
senderKey: string;
templateCode?: string;
responseMethod?: string;
}
export interface NtftalkMmtLogHistoryUpdatePayload {
content: string;
recipientNum: string;
msgStatus?: string;
reportCode?: string;
}
export type NtftalkMmtLogHistoryPageList = PageableList<NtftalkMmtLogHistory>;
export interface NtftalkMmtLogHistoryService {
getNtftalkMmtLogHistoryPage(
params: NtftalkMmtLogHistorySearchParams,
): Promise<NtftalkMmtLogHistoryPageList>;
getNtftalkMmtLogHistory(mtPr: number): Promise<NtftalkMmtLogHistory>;
saveNtftalkMmtLogHistory(
payload: NtftalkMmtLogHistorySavePayload,
): Promise<NtftalkMmtLogHistory>;
updateNtftalkMmtLogHistory(
mtPr: number,
payload: NtftalkMmtLogHistoryUpdatePayload,
): Promise<NtftalkMmtLogHistory>;
deleteNtftalkMmtLogHistory(mtPr: number): Promise<void>;
}
+1 -1
View File
@@ -101,7 +101,7 @@ function FaqPage({ bbsId }: { bbsId: string }) {
alert("삭제되었습니다.");
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
@@ -174,7 +174,7 @@ function AddAdminDialog() {
}
};
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
const handleSearch = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
setPagination({ pageIndex: 0, pageSize: 10 });
+1 -1
View File
@@ -125,7 +125,7 @@ function AdminPage() {
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
+1 -1
View File
@@ -75,7 +75,7 @@ function ApiLogPage() {
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -9,7 +9,7 @@ import "./login.css";
function LoginPage() {
const [errorMessage, setErrorMessage] = useState<string>("");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(""); // 에러 메시지 초기화
+1 -1
View File
@@ -48,7 +48,7 @@ function BannerPage() {
}),
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
+1 -1
View File
@@ -51,7 +51,7 @@ function BbsPage() {
}),
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -107,7 +107,7 @@ function BoardStatisticsPage() {
setPresetDateRange("");
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const params: BoardStatisticsParams = {
siteId,
@@ -65,7 +65,7 @@ function LwpoCodeSection({ upCd }: LwpoCodeSectionProps) {
dataTableOptions: { viewPagination: true },
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
+1 -1
View File
@@ -40,7 +40,7 @@ function UpCodeSection({ handleSelectedUpCd }: UpCodeSectionProps) {
handleSelectedUpCd(row.upCd);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -38,7 +38,7 @@ function ContentsPage() {
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
+1 -1
View File
@@ -65,7 +65,7 @@ function FilePage() {
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -149,7 +149,7 @@ function AddUserDialog({ groupId }: AdditionalUserDialogProps) {
}
};
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
const handleSearch = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
setPagination({
@@ -12,7 +12,7 @@ import {
type RowSelectionState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button,Input, Label } from "@common/components";
import { Button, Input, Label } from "@common/components";
import { GroupUsersApi } from "@/entities/group-user";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable } from "@/shared/lib";
@@ -33,8 +33,8 @@ function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
pageSize: 10,
});
const [userNm, setUserNm] = useState("");
const [userNm, setUserNm] = useState("");
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const query = useQuery({
@@ -50,7 +50,7 @@ function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -116,27 +116,27 @@ function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
return (
<div className="flex-1">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
이름
</Label>
<Input
className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]"
value={userNm}
onChange={(e) => setUserNm(e.target.value)}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
<Button
type="button"
color="white"
className="h-[3.2rem]"
onClick={handleReset}
>
초기화
</Button>
</form>
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
이름
</Label>
<Input
className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]"
value={userNm}
onChange={(e) => setUserNm(e.target.value)}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
<Button
type="button"
color="white"
className="h-[3.2rem]"
onClick={handleReset}
>
초기화
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
+42 -44
View File
@@ -8,13 +8,13 @@ import {
} from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { GroupApi , type GroupSearchParams} from "@/entities/group";
import { GroupApi, type GroupSearchParams } from "@/entities/group";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { createColumns } from "../model/groups-table-columns";
import { GroupsDialog } from "./groups-dialog";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
type GroupsSectionProps = {
handleSelectedGroup: (groupId: string) => void;
@@ -29,14 +29,12 @@ function GroupsSection({ handleSelectedGroup }: GroupsSectionProps) {
const [groupNm, setGroupNm] = useState("");
const [searchParams, setSearchParams] = useState<GroupSearchParams>({
groupNm: "",
delYn :"N",
sort : "groupId,desc",
delYn: "N",
sort: "groupId,desc",
});
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.GROUPS,searchParams, pagination],
queryKey: [ENDPOINT.CMS_SERVICE.GROUPS, searchParams, pagination],
queryFn: () =>
GroupApi.findPageGroup({
...searchParams,
@@ -64,7 +62,7 @@ function GroupsSection({ handleSelectedGroup }: GroupsSectionProps) {
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -81,7 +79,7 @@ function GroupsSection({ handleSelectedGroup }: GroupsSectionProps) {
setSearchParams({
...searchParams,
groupNm: "",
delYn:"N"
delYn: "N",
});
};
const handleRowClick = (row: { groupId: string }) => {
@@ -109,41 +107,41 @@ function GroupsSection({ handleSelectedGroup }: GroupsSectionProps) {
return (
<div className="flex-1">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
그룹명
</Label>
<Input
className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]"
value={groupNm}
onChange={(e) => setGroupNm(e.target.value)}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
<Button
type="button"
color="white"
className="h-[3.2rem]"
onClick={handleReset}
>
초기화
</Button>
<Label htmlFor="delYn" className="ml-4 mr-2 text-label">
삭제여부
</Label>
<input
id="delYn"
type="checkbox"
checked={searchParams.delYn === "Y"}
onChange={(e) =>
setSearchParams({
...searchParams,
delYn: e.target.checked ? "Y" : "N",
})
}
/>
</form>
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
그룹명
</Label>
<Input
className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]"
value={groupNm}
onChange={(e) => setGroupNm(e.target.value)}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
<Button
type="button"
color="white"
className="h-[3.2rem]"
onClick={handleReset}
>
초기화
</Button>
<Label htmlFor="delYn" className="ml-4 mr-2 text-label">
삭제여부
</Label>
<input
id="delYn"
type="checkbox"
checked={searchParams.delYn === "Y"}
onChange={(e) =>
setSearchParams({
...searchParams,
delYn: e.target.checked ? "Y" : "N",
})
}
/>
</form>
</div>
<div className="card card-border">
<div className="card-header">
@@ -95,7 +95,7 @@ function MenuAuthorityDialog({ groupId }: MenuAuthorityDialogProps) {
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const keyword = formData.get("keyword") as string;
+1 -1
View File
@@ -46,7 +46,7 @@ function ImagePage() {
}),
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
+158 -134
View File
@@ -4,7 +4,17 @@ import "@common/assets/styles/grid.css";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button, Input, Label, LoadingWithAnimation, Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@common/components";
import {
Button,
Input,
Label,
LoadingWithAnimation,
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@common/components";
import { getCoreRowModel } from "@tanstack/react-table";
import { LinkApi, type Link, type LinkSearchParam } from "@/entities/link";
import { useQuery } from "@tanstack/react-query";
@@ -14,147 +24,161 @@ import { useDataTable } from "@/shared/lib";
import LinkExpiredAlert from "./LinkExpiredAlert";
function LinkPage() {
const router = useRouter();
const queryClient = useQueryClient();
const router = useRouter();
const queryClient = useQueryClient();
const [linkSn, setLinkSn] = useState<string>('');
const [linkNm, setLinkNm] = useState<string>('');
const [linkClfsCd, setLinkClfsCd] = useState<string>('');
const [linkSn, setLinkSn] = useState<string>("");
const [linkNm, setLinkNm] = useState<string>("");
const [linkClfsCd, setLinkClfsCd] = useState<string>("");
const [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: '',
linkNm: '',
linkClfsCd: '',
const [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: "",
linkNm: "",
linkClfsCd: "",
});
const query = useQuery({
queryKey: [
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`,
linkSearchParam,
],
queryFn: () => LinkApi.getLinkList(linkSearchParam),
});
const handleResetSearchParam = () => {
setLinkSn("");
setLinkNm("");
setLinkClfsCd("ALL");
setLinkSearchParam({
...linkSearchParam,
linkSn: "",
linkNm: "",
linkClfsCd: "ALL",
});
};
const query = useQuery({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, linkSearchParam],
queryFn: () => LinkApi.getLinkList(linkSearchParam),
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setLinkSearchParam({
...linkSearchParam,
linkSn,
linkNm,
linkClfsCd,
});
};
const handleResetSearchParam = () => {
setLinkSn('');
setLinkNm('');
setLinkClfsCd('ALL');
setLinkSearchParam({
...linkSearchParam,
linkSn: '',
linkNm: '',
linkClfsCd: 'ALL',
});
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLinkSearchParam({
...linkSearchParam,
linkSn,
linkNm,
linkClfsCd,
});
};
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,
},
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}`);
};
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="liSnSearchInput" className="mr-2 text-label">
일련번호
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-4"
id="liSnSearchInput"
value={linkSn}
onChange={(e) => setLinkSn(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liNmSearchInput" className="mr-2 text-label">
연계 명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-2"
id="liNmSearchInput"
value={linkNm}
onChange={(e) => setLinkNm(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="linkClfsCd" className="mr-2 text-label">
연계 분류
</Label>
<Select onValueChange={(e) => setLinkClfsCd(e)} value={linkClfsCd}>
<SelectTrigger id="linkClfsCd" className="w-[20rem] mr-4">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
<SelectItem value="link0001">TIBERO 데이터베이스</SelectItem>
<SelectItem value="link0002">ORACLE 데이터베이스</SelectItem>
<SelectItem value="link0003">공공데이터 포털 API</SelectItem>
<SelectItem value="link0004">한강홍수통제소 API</SelectItem>
<SelectItem value="link0005">기상청 API</SelectItem>
<SelectItem value="link0006">국가지하수정보센터 API</SelectItem>
<SelectItem value="link0007">행정정보공동이용센터 연계</SelectItem>
</SelectContent>
</Select>
<Button color="green" className="h-[3.2rem]" type="button" onClick={handleResetSearchParam}>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border flex flex-col h-[calc(100vh-28rem)]">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">연계 목록</h3>
<div className="flex items-center">
<LinkExpiredAlert linkList={query} />
<Button onClick={handleClickRegist}>등록</Button>
</div>
</div>
<div className="card !m-[1.2rem] overflow-auto h-full !p-0">
{!query.isLoading ?
<div>{tableComponent}</div>
:
<div className="[data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center h-full">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
}
</div>
</div>
const handleClickRegist = () => {
router.push("link/register");
};
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns,
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
onRowClick: handleRowClick,
viewPagination: true,
},
});
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="liSnSearchInput" className="mr-2 text-label">
일련번호
</Label>
<Input
className="mr-4 h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
id="liSnSearchInput"
value={linkSn}
onChange={(e) => setLinkSn(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liNmSearchInput" className="mr-2 text-label">
연계 명
</Label>
<Input
className="mr-2 h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
id="liNmSearchInput"
value={linkNm}
onChange={(e) => setLinkNm(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="linkClfsCd" className="mr-2 text-label">
연계 분류
</Label>
<Select onValueChange={(e) => setLinkClfsCd(e)} value={linkClfsCd}>
<SelectTrigger id="linkClfsCd" className="mr-4 w-[20rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
<SelectItem value="link0001">TIBERO 데이터베이스</SelectItem>
<SelectItem value="link0002">ORACLE 데이터베이스</SelectItem>
<SelectItem value="link0003">공공데이터 포털 API</SelectItem>
<SelectItem value="link0004">한강홍수통제소 API</SelectItem>
<SelectItem value="link0005">기상청 API</SelectItem>
<SelectItem value="link0006">국가지하수정보센터 API</SelectItem>
<SelectItem value="link0007">
행정정보공동이용센터 연계
</SelectItem>
</SelectContent>
</Select>
<Button
color="green"
className="h-[3.2rem]"
type="button"
onClick={handleResetSearchParam}
>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border flex h-[calc(100vh-28rem)] flex-col">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">연계 목록</h3>
<div className="flex items-center">
<LinkExpiredAlert linkList={query} />
<Button onClick={handleClickRegist}>등록</Button>
</div>
</div>
);
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
{!query.isLoading ? (
<div>{tableComponent}</div>
) : (
<div className="[data-theme='black']:bg-transparent flex h-full items-center justify-center rounded-lg p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
</div>
</div>
);
}
export { LinkPage };
export { LinkPage };
+205 -166
View File
@@ -3,10 +3,25 @@
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 {
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 {
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";
@@ -14,176 +29,200 @@ import { useAlertStore } from "@/shared/lib/use-alert-store";
import { useDataTable } from "@/shared/lib";
function LinkLogPage() {
const { setMessage: alert } = useAlertStore((state) => state);
const { setMessage: alert } = useAlertStore((state) => state);
const [linkSn, setLinkSn] = useState<string>('');
const [linkNm, setLinkNm] = useState<string>('');
const [isSucc, setIsSucc] = useState<string>('ALL');
const [startDate, setStartDate] = useState<string>(dayjs().subtract(7, "day").format('YYYY-MM-DD'));
const [endDate, setEndDate] = useState<string>(dayjs().format('YYYY-MM-DD'));
const [linkSn, setLinkSn] = useState<string>("");
const [linkNm, setLinkNm] = useState<string>("");
const [isSucc, setIsSucc] = useState<string>("ALL");
const [startDate, setStartDate] = useState<string>(
dayjs().subtract(7, "day").format("YYYY-MM-DD"),
);
const [endDate, setEndDate] = useState<string>(dayjs().format("YYYY-MM-DD"));
const [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: '',
linkNm: '',
isSucc: '',
startDate: '',
endDate: '',
const [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: "",
linkNm: "",
isSucc: "",
startDate: "",
endDate: "",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 100,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
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 [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 100,
});
const handleSubmit = async (e?: React.SubmitEvent<HTMLFormElement>) => {
e?.preventDefault();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
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'),
if (startDate && endDate) {
if (startDate > endDate) {
alert("종료일이 시작일보다 이전일 수 없습니다.");
return;
} else {
setPagination({
...pagination,
pageIndex: 0,
});
};
setLinkSearchParam({
...linkSearchParam,
linkSn,
linkNm,
isSucc,
startDate,
endDate,
});
}
} else {
alert("조회 날짜를 선택해주세요.");
return;
}
};
const handleSubmit = async (e?: React.FormEvent<HTMLFormElement>) => {
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 (
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="liSnSearchInput" className="mr-2 text-label">
일련번호
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-4"
id="liSnSearchInput"
value={linkSn}
onChange={(e) => setLinkSn(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liNmSearchInput" className="mr-2 text-label">
연계 명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-4"
id="liNmSearchInput"
value={linkNm}
onChange={(e) => setLinkNm(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liResultCode" className="mr-2 text-label">
실행결과
</Label>
<Select onValueChange={(e) => setIsSucc(e)} value={isSucc}>
<SelectTrigger id="liResultCode" className="w-[10rem] mr-4">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
<SelectItem value="SUCC">성공</SelectItem>
<SelectItem value="FAIL">실패</SelectItem>
</SelectContent>
</Select>
<Label htmlFor="sDateSearchInput" className="mx-2 text-label">
시작일
</Label>
<DatePicker id="sDateSearchInput" mode={"single"} selected={dayjs(startDate).toDate()} onSelect={(e) => setStartDate(dayjs(e).format('YYYY-MM-DD'))} />
<Label htmlFor="eDateSearchInput" className="mx-2 text-label">
종료일
</Label>
<DatePicker id="eDateSearchInput" mode={"single"} className="mr-2" selected={dayjs(endDate).toDate()} onSelect={(e) => setEndDate(dayjs(e).format('YYYY-MM-DD'))} />
<Button color="green" className="h-[3.2rem]" type="button" onClick={handleResetSearchParam}>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border flex flex-col h-[calc(100vh-28rem)]">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">로그 목록 (검색 조건에 따른 건수 : {query.data?.totalElements.toLocaleString('ko-KR') ?? 0} )</h3>
</div>
<div className="card !m-[1.2rem] overflow-auto h-full !p-0">
{query.isLoading ?
<div className="[data-theme='black']:bg-transparent rounded-lg p-10 h-full flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
:
<div>{tableComponent}</div>
}
</div>
</div>
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="liSnSearchInput" className="mr-2 text-label">
일련번호
</Label>
<Input
className="mr-4 h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
id="liSnSearchInput"
value={linkSn}
onChange={(e) => setLinkSn(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liNmSearchInput" className="mr-2 text-label">
연계 명
</Label>
<Input
className="mr-4 h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
id="liNmSearchInput"
value={linkNm}
onChange={(e) => setLinkNm(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liResultCode" className="mr-2 text-label">
실행결과
</Label>
<Select onValueChange={(e) => setIsSucc(e)} value={isSucc}>
<SelectTrigger id="liResultCode" className="mr-4 w-[10rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
<SelectItem value="SUCC">성공</SelectItem>
<SelectItem value="FAIL">실패</SelectItem>
</SelectContent>
</Select>
<Label htmlFor="sDateSearchInput" className="mx-2 text-label">
시작일
</Label>
<DatePicker
id="sDateSearchInput"
mode={"single"}
selected={dayjs(startDate).toDate()}
onSelect={(e) => setStartDate(dayjs(e).format("YYYY-MM-DD"))}
/>
<Label htmlFor="eDateSearchInput" className="mx-2 text-label">
종료일
</Label>
<DatePicker
id="eDateSearchInput"
mode={"single"}
className="mr-2"
selected={dayjs(endDate).toDate()}
onSelect={(e) => setEndDate(dayjs(e).format("YYYY-MM-DD"))}
/>
<Button
color="green"
className="h-[3.2rem]"
type="button"
onClick={handleResetSearchParam}
>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border flex h-[calc(100vh-28rem)] flex-col">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
로그 목록 (검색 조건에 따른 건수 :{" "}
{query.data?.totalElements.toLocaleString("ko-KR") ?? 0} )
</h3>
</div>
)
};
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
{query.isLoading ? (
<div className="[data-theme='black']:bg-transparent flex h-full items-center justify-center rounded-lg p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
) : (
<div>{tableComponent}</div>
)}
</div>
</div>
</div>
);
}
export { LinkLogPage };
export { LinkLogPage };
@@ -1,20 +1,31 @@
'use client';
"use client";
import { useState, useMemo } from "react";
import {
Button,
DataTable,
Input,
Label,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
LoadingWithAnimation,
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 {
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";
@@ -31,376 +42,388 @@ import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { useDataTable } from "@/shared/lib";
function LinkParamsPage() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
const [linkSn, setLinkSn] = useState<string>('');
const [linkNm, setLinkNm] = useState<string>('');
const [linkSn, setLinkSn] = useState<string>("");
const [linkNm, setLinkNm] = useState<string>("");
const [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: '',
linkNm: ''
const [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: "",
linkNm: "",
});
const [selectedLink, setSelectedLink] = useState<{
linkSn: string;
linkNm: string;
}>({
linkSn: "",
linkNm: "",
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 100,
});
const { confirm } = useConfirm();
const [selectedVrblIds, setSelectedVrblIds] = useState<string[]>([]);
const [isDBdata, setIsDBData] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [onConfirm, setOnConfirm] = useState<boolean>(false);
const userUniqId = getCookie(AUTH_USER_ID) ?? "";
const { setMessage: alert } = useAlertStore((state) => state);
const dbIdList = ["link0001", "link0002", "link0007"];
// Link 목록 쿼리
const query = useQuery({
queryKey: ["api/link/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: "",
});
const [selectedLink, setSelectedLink] = useState<{
linkSn: string;
linkNm: string;
}>({
linkSn: '',
linkNm: ''
setLinkSn("");
setLinkNm("");
setLinkSearchParam({
...linkSearchParam,
linkSn: "",
linkNm: "",
});
};
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const { mutateAsync: insertLinkVrbl } = useMutation({
mutationFn: async (data: LinkVrblFormData) =>
await LinkApi.insertLinkVrbl({
...data,
link_sn: selectedLink.linkSn,
mdfr_id: 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_id: 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.refetchQueries({
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<LinkVrblFormData>({
resolver: zodResolver(linkVrblFormSchema),
defaultValues: {},
});
const handleRowClick = async (link: Link) => {
setSelectedLink({
linkSn: link.link_sn,
linkNm: link.link_nm,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
setSelectedVrblIds([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 100,
if (dbIdList.includes(link.link_clsf_cd)) {
setIsDBData(true);
} else {
setIsDBData(false);
}
};
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setSelectedLink({
linkSn: "",
linkNm: "",
});
const { confirm } = useConfirm();
const [selectedVrblIds, setSelectedVrblIds] = useState<string[]>([]);
const [isDBdata, setIsDBData] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [onConfirm, setOnConfirm] = useState<boolean>(false);
const userUniqId = getCookie(AUTH_USER_ID) ?? "";
const { setMessage: alert } = useAlertStore((state) => state);
const dbIdList = ['link0001', 'link0002', 'link0007'];
// Link 목록 쿼리
const query = useQuery({
queryKey: ['api/link/getLinkList', linkSearchParam],
queryFn: () => LinkApi.getLinkList({
...linkSearchParam,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
setLinkSearchParam({
...linkSearchParam,
linkSn,
linkNm,
});
};
// LinkVrbl 목록 쿼리 - 별도 관리
const linkVrblQuery = useQuery({
queryKey: ['linkVrblList', selectedLink.linkSn],
queryFn: () => LinkApi.getLinkVrblList(selectedLink.linkSn),
enabled: !!selectedLink.linkSn && !dbIdList.includes(selectedLink.linkSn),
staleTime: 0, // 항상 최신 데이터를 가져오도록 설정
});
const fetchUserInfo = async () => {
const userData = await getUserInfo();
return userData.userName;
};
const handleResetSearchParam = () => {
setSelectedLink({
linkSn: '',
linkNm: ''
});
setLinkSn('');
setLinkNm('');
setLinkSearchParam({
...linkSearchParam,
linkSn: '',
linkNm: ''
});
};
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const { mutateAsync: insertLinkVrbl } = useMutation({
mutationFn: async (data: LinkVrblFormData) =>
await LinkApi.insertLinkVrbl({
...data,
link_sn: selectedLink.linkSn,
mdfr_id: userUniqId,
}),
onSuccess: async () => {
// 쿼리 무효화를 통한 최신 데이터 가져오기
await queryClient.invalidateQueries({
queryKey: ['linkVrblList', selectedLink.linkSn]
});
setDialogOpen(false);
alert('저장되었습니다.');
const handleDeleteParam = async () => {
if (selectedVrblIds.length === 0) {
alert("삭제할 파라미터를 선택해 주세요.");
} else {
await handleConfirm(async () => {
if (await confirm("선택한 파라미터 정보를 삭제하시겠습니까?")) {
deleteLinkVrbl(selectedVrblIds);
}
});
});
}
};
const { mutateAsync: updateLinkVrbl } = useMutation({
mutationFn: async (data: LinkVrblFormData) => await LinkApi.updateLinkVrblInfo({
...data,
link_sn: selectedLink.linkSn,
mdfr_id: userUniqId,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['linkVrblList', selectedLink.linkSn]
});
setDialogOpen(false);
alert('저장되었습니다.');
}
});
const handleVrblRowClick = async (data: LinkVrbl) => {
const linkVrblInfo = await LinkApi.getLinkVrblInfo(data.link_vrbl_sn);
const { mutateAsync: deleteLinkVrbl } = useMutation({
mutationFn: async (data: string[]) => await LinkApi.deleteLinkVrbl(data),
onSuccess: async () => {
await queryClient.refetchQueries({
queryKey: ['linkVrblList', selectedLink.linkSn]
});
setSelectedVrblIds([]);
alert('삭제되었습니다.');
}
});
if (linkVrblInfo) {
let newData = { ...linkVrblInfo };
// linkVrblQuery의 데이터를 사용
const tableData = useMemo(() => {
return linkVrblQuery.data?.content ?? [];
}, [linkVrblQuery.data]);
if (linkVrblInfo.mdfr_id) {
const userName = await fetchUserInfo();
newData = {
...linkVrblInfo,
mdfr_id: userName,
};
}
const vrblCols = useMemo(
() =>
vrblColumns({
selectedVrblIds,
setSelectedVrblIds,
}),
[selectedVrblIds]
);
form.reset(newData);
}
setDialogOpen(true);
};
const vrblTable = useReactTable({
data: tableData,
columns: vrblCols,
getCoreRowModel: getCoreRowModel(),
});
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns,
state: {
pagination,
rowSelection,
},
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
onRowClick: handleRowClick,
viewPagination: true,
},
});
const form = useForm<LinkVrblFormData>({
resolver: zodResolver(linkVrblFormSchema),
defaultValues: {},
});
// LinkVrbl 데이터 표시 로직
const renderLinkVrblContent = () => {
if (isDBdata) {
return (
<div className="mt-14 flex flex-1 items-center justify-center text-3xl text-gray-500">
변수를 지원하지 않는 연계 데이터입니다.
</div>
);
}
const handleRowClick = async (link: Link) => {
setSelectedLink({
linkSn: link.link_sn,
linkNm: link.link_nm,
});
if (!selectedLink.linkSn) {
return (
<div className="mt-14 flex flex-1 items-center justify-center text-3xl text-gray-500">
연계 데이터를 선택해주세요.
</div>
);
}
setSelectedVrblIds([]);
if (dbIdList.includes(link.link_clsf_cd)) {
setIsDBData(true);
} else {
setIsDBData(false);
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSelectedLink({
linkSn: '',
linkNm: ''
});
setLinkSearchParam({
...linkSearchParam,
linkSn,
linkNm
});
};
const fetchUserInfo = async () => {
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_id) {
const userName = await fetchUserInfo();
newData = {
...linkVrblInfo,
mdfr_id: 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 (
<div className="flex items-center justify-center mt-14 text-gray-500 text-3xl flex-1">
변수를 지원하지 않는 연계 데이터입니다.
</div>
);
}
if (!selectedLink.linkSn) {
return (
<div className="flex items-center justify-center mt-14 text-gray-500 text-3xl flex-1">
연계 데이터를 선택해주세요.
</div>
);
}
if (linkVrblQuery.isLoading) {
return (
<div className="[data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center h-full">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
);
}
return (
<>
<div className="mb-2 mt-14 flex justify-end items-center flex-none">
{tableData.length > 0 && (
<Button color="red" className="h-[3.2rem] mr-2" type="button" onClick={handleDeleteParam}>
행 삭제
</Button>
)}
<LinkParamsDialog
selectedLink={selectedLink}
form={form}
insertLinkVrbl={insertLinkVrbl}
dialogOpen={dialogOpen}
onOpenChange={setDialogOpen}
updateLinkVrbl={updateLinkVrbl}
/>
</div>
{tableData.length > 0 ? (
<div className="overflow-auto flex-1 min-h-[300px]">
<DataTable table={vrblTable} onRowClick={handleVrblRowClick} />
</div>
) : (
<div className="flex items-center justify-center h-20 text-gray-500 text-3xl">
등록된 연계 변수가 없습니다.
</div>
)}
</>
);
};
if (linkVrblQuery.isLoading) {
return (
<div className="[data-theme='black']:bg-transparent flex h-full items-center justify-center rounded-lg p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
);
}
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="liSnSearchInput" className="mr-2 text-label">
일련번호
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-4"
id="liSnSearchInput"
value={linkSn}
onChange={(e) => setLinkSn(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liNmSearchInput" className="mr-2 text-label">
연계 명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-2"
id="liNmSearchInput"
value={linkNm}
onChange={(e) => setLinkNm(e.target.value)}
autoComplete="off"
/>
<Button color="green" className="h-[3.2rem]" type="button" onClick={handleResetSearchParam}>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card !mb-0 flex h-[calc(100vh-28rem)] gap-5 !p-0">
<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-[1.2rem] overflow-auto !p-0 h-full">
{!query.isLoading ? (
<div>{tableComponent}</div>
) : (
<div className="[data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center h-full">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
</div>
<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-[1.2rem] !p-0 overflow-auto h-full flex flex-col">
<Table variant="secondary" className="flex-none">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>연계 일련번호</TableHead>
<TableCell>
{selectedLink.linkSn ? selectedLink.linkSn : '-'}
</TableCell>
</TableRow>
<TableRow>
<TableHead>연계 명</TableHead>
<TableCell>
{selectedLink.linkNm ? selectedLink.linkNm : '-'}
</TableCell>
</TableRow>
</TableBody>
</Table>
{renderLinkVrblContent()}
</div>
</div>
</div>
<ConfirmDialog isOpen={onConfirm} />
<>
<div className="mb-2 mt-14 flex flex-none items-center justify-end">
{tableData.length > 0 && (
<Button
color="red"
className="mr-2 h-[3.2rem]"
type="button"
onClick={handleDeleteParam}
>
행 삭제
</Button>
)}
<LinkParamsDialog
selectedLink={selectedLink}
form={form}
insertLinkVrbl={insertLinkVrbl}
dialogOpen={dialogOpen}
onOpenChange={setDialogOpen}
updateLinkVrbl={updateLinkVrbl}
/>
</div>
{tableData.length > 0 ? (
<div className="min-h-[300px] flex-1 overflow-auto">
<DataTable table={vrblTable} onRowClick={handleVrblRowClick} />
</div>
) : (
<div className="flex h-20 items-center justify-center text-3xl text-gray-500">
등록된 연계 변수가 없습니다.
</div>
)}
</>
);
};
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="liSnSearchInput" className="mr-2 text-label">
일련번호
</Label>
<Input
className="mr-4 h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
id="liSnSearchInput"
value={linkSn}
onChange={(e) => setLinkSn(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liNmSearchInput" className="mr-2 text-label">
연계 명
</Label>
<Input
className="mr-2 h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
id="liNmSearchInput"
value={linkNm}
onChange={(e) => setLinkNm(e.target.value)}
autoComplete="off"
/>
<Button
color="green"
className="h-[3.2rem]"
type="button"
onClick={handleResetSearchParam}
>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card !mb-0 flex h-[calc(100vh-28rem)] gap-5 !p-0">
<div className="card card-border !mx-0 flex w-1/2 flex-col">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">연계 목록</h3>
</div>
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
{!query.isLoading ? (
<div>{tableComponent}</div>
) : (
<div className="[data-theme='black']:bg-transparent flex h-full items-center justify-center rounded-lg p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
</div>
<div className="card card-border !mx-0 flex w-1/2 flex-col">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">연계 변수 정보</h3>
</div>
<div className="card !m-[1.2rem] flex h-full flex-col overflow-auto !p-0">
<Table variant="secondary" className="flex-none">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>연계 일련번호</TableHead>
<TableCell>
{selectedLink.linkSn ? selectedLink.linkSn : "-"}
</TableCell>
</TableRow>
<TableRow>
<TableHead>연계 명</TableHead>
<TableCell>
{selectedLink.linkNm ? selectedLink.linkNm : "-"}
</TableCell>
</TableRow>
</TableBody>
</Table>
{renderLinkVrblContent()}
</div>
</div>
</div>
<ConfirmDialog isOpen={onConfirm} />
</div>
);
}
export { LinkParamsPage };
export { LinkParamsPage };
+395 -342
View File
@@ -3,376 +3,429 @@
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,
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 {
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;
linkSuccStats: LinkStats[] | null;
linkFailStats: LinkStats[] | null;
}
function LinkStatsPage() {
const succChartRef = useRef<Chart<"line"> | null>(null);
const failChartRef = useRef<Chart<"line"> | null>(null);
const succChartRef = useRef<Chart<"line"> | null>(null);
const failChartRef = useRef<Chart<"line"> | null>(null);
const [startDate, setStartDate] = useState<string>(
dayjs().subtract(7, "day").format("YYYY-MM-DD"),
);
const [endDate, setEndDate] = useState<string>(dayjs().format("YYYY-MM-DD"));
const [startDate, setStartDate] = useState<string>(dayjs().subtract(7, "day").format('YYYY-MM-DD'));
const [endDate, setEndDate] = useState<string>(dayjs().format('YYYY-MM-DD'));
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 100,
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 100,
const [linkStatsList, setLinkStatsList] =
useState<LinkStatsListObject | null>(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<LinkSearchParam>({
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"),
});
const [linkStatsList, setLinkStatsList] = useState<LinkStatsListObject | null>(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,
setLogQueryParam({
isSucc: "SUCC",
linkNm: "",
linkSn: "",
});
setStartDate(dayjs().subtract(7, "day").format("YYYY-MM-DD"));
setEndDate(dayjs().format("YYYY-MM-DD"));
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns,
state: {
pagination,
rowSelection,
},
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
viewPagination: true,
},
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 [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: '',
linkNm: '',
startDate: '',
endDate: '',
});
const handleSubmit = async (e?: React.SubmitEvent<HTMLFormElement>) => {
e?.preventDefault();
ChartJS.register(
LineElement,
CategoryScale,
LinearScale,
PointElement,
Tooltip,
Legend
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<HTMLCanvasElement>,
) => {
if (!succChartRef.current) return;
const chart = succChartRef.current;
const points = chart.getElementsAtEventForMode(
event.nativeEvent,
"nearest",
{ intersect: true },
false,
);
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,
},
],
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<HTMLCanvasElement>,
) => {
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);
};
const succOptions: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
},
scales: {
y: {
min: 0,
},
},
};
setLinkSearchParam({
...linkSearchParam,
startDate,
endDate,
});
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,
},
],
};
fetchData();
}, []);
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<HTMLFormElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 (
<div className="card !mb-0 !min-h-[calc(100vh-16.8rem)]">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="sDateSearchInput" className="mx-2 text-label">
시작일
</Label>
<DatePicker id="sDateSearchInput" mode={"single"} selected={dayjs(startDate).toDate()} onSelect={(e) => setStartDate(dayjs(e).format('YYYY-MM-DD'))} />
<Label htmlFor="eDateSearchInput" className="mx-2 text-label">
종료일
</Label>
<DatePicker id="eDateSearchInput" mode={"single"} className="mr-2" selected={dayjs(endDate).toDate()} onSelect={(e) => setEndDate(dayjs(e).format('YYYY-MM-DD'))} />
<Button color="green" className="h-[3.2rem]" type="button" onClick={handleResetSearchParam}>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
<Button className="h-[3.2rem]" type="button" onClick={handleSubmitToday}>
오늘 날짜로 조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card !m-[1.2rem] !p-0 flex flex-col gap-y-8">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">조회 일자 : {`${linkSearchParam.startDate} ~ ${linkSearchParam.endDate}`}</h3>
</div>
<div className="h-[32rem] w-full">
<Line ref={succChartRef} data={succData} options={succOptions} onClick={handleSuccChartClick} />
</div>
<div className="h-[32rem]">
<Line ref={failChartRef} data={failData} options={failOptions} onClick={handleFailChartClick} />
</div>
</div>
<div className="card !m-[1.2rem] !p-0">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{logQueryParam?.linkNm ? `${logQueryParam.linkNm} 연계 ${logQueryParam.isSucc === 'FAIL' ? '실패' : '성공'} 로그 목록 (${linkSearchParam.startDate} ~ ${linkSearchParam.endDate}) - ${query.data?.totalElements.toLocaleString('ko-KR') ?? 0}건` : '로그 목록'}
</h3>
</div>
<div className="card !m-[1.2rem] h-[40rem] overflow-auto !p-0">
{logQueryParam?.linkNm ?
query.isLoading ?
<div className="[data-theme='black']:bg-transparent rounded-lg p-10 h-full flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
:
<div>{tableComponent}</div>
:
<div className="flex items-center justify-center h-full text-gray-500 text-3xl">
차트 데이터를 선택해주세요.
</div>
}
</div>
</div>
</div>
return (
<div className="card !mb-0 !min-h-[calc(100vh-16.8rem)]">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="sDateSearchInput" className="mx-2 text-label">
시작일
</Label>
<DatePicker
id="sDateSearchInput"
mode={"single"}
selected={dayjs(startDate).toDate()}
onSelect={(e) => setStartDate(dayjs(e).format("YYYY-MM-DD"))}
/>
<Label htmlFor="eDateSearchInput" className="mx-2 text-label">
종료일
</Label>
<DatePicker
id="eDateSearchInput"
mode={"single"}
className="mr-2"
selected={dayjs(endDate).toDate()}
onSelect={(e) => setEndDate(dayjs(e).format("YYYY-MM-DD"))}
/>
<Button
color="green"
className="h-[3.2rem]"
type="button"
onClick={handleResetSearchParam}
>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
<Button
className="h-[3.2rem]"
type="button"
onClick={handleSubmitToday}
>
오늘 날짜로 조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card !m-[1.2rem] flex flex-col gap-y-8 !p-0">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
조회 일자 :{" "}
{`${linkSearchParam.startDate} ~ ${linkSearchParam.endDate}`}
</h3>
</div>
<div className="h-[32rem] w-full">
<Line
ref={succChartRef}
data={succData}
options={succOptions}
onClick={handleSuccChartClick}
/>
</div>
<div className="h-[32rem]">
<Line
ref={failChartRef}
data={failData}
options={failOptions}
onClick={handleFailChartClick}
/>
</div>
</div>
)
};
<div className="card !m-[1.2rem] !p-0">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{logQueryParam?.linkNm
? `${logQueryParam.linkNm} 연계 ${logQueryParam.isSucc === "FAIL" ? "실패" : "성공"} 로그 목록 (${linkSearchParam.startDate} ~ ${linkSearchParam.endDate}) - ${query.data?.totalElements.toLocaleString("ko-KR") ?? 0}건`
: "로그 목록"}
</h3>
</div>
<div className="card !m-[1.2rem] h-[40rem] overflow-auto !p-0">
{logQueryParam?.linkNm ? (
query.isLoading ? (
<div className="[data-theme='black']:bg-transparent flex h-full items-center justify-center rounded-lg p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
) : (
<div>{tableComponent}</div>
)
) : (
<div className="flex h-full items-center justify-center text-3xl text-gray-500">
차트 데이터를 선택해주세요.
</div>
)}
</div>
</div>
</div>
</div>
);
}
export { LinkStatsPage };
export { LinkStatsPage };
File diff suppressed because it is too large Load Diff
@@ -77,7 +77,7 @@ function LoginlogPage() {
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setIsManualLoading(true);
setPagination({
@@ -91,7 +91,7 @@ function MenuHistoryDialog({ menuCd }: MenuHistoryDialogProps) {
},
});
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
const handleSearch = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
query.refetch();
@@ -59,7 +59,7 @@ function OrganizationSearchDialog({
handleUserSelect(node.data as TreeUser);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
setTree(filterUserOrganizationTreeDataByKeyword(treeData ?? [], keyword));
+1 -1
View File
@@ -62,7 +62,7 @@ function ModulePage() {
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -0,0 +1 @@
export { NtftalkHistoryPage } from "./ui/ntftalk-history-page";
@@ -0,0 +1,64 @@
import type { ColumnDef } from "@tanstack/react-table";
import type { NtftalkMmtLogHistory } from "@/entities/ntftalk";
function formatDateTime(value: string | null | undefined): string {
if (!value) return "—";
const normalized = value.includes("T")
? value.replace("T", " ").slice(0, 19)
: value.slice(0, 19);
return normalized;
}
function formatResult(successYn: string | null | undefined): string {
if (!successYn) return "—";
if (successYn === "SUCCESS") return "성공";
if (successYn === "FAIL") return "실패";
return successYn;
}
export function createNtftalkHistoryColumns(): ColumnDef<NtftalkMmtLogHistory>[] {
return [
{
id: "rowNo",
header: "순번",
enableSorting: false,
cell: ({ row, table }) => {
const { pageIndex, pageSize } = table.getState().pagination;
return pageIndex * pageSize + row.index + 1;
},
},
{
accessorKey: "siteSystem",
header: "시스템",
cell: ({ getValue }) => (getValue() as string) || "—",
},
{
accessorKey: "content",
header: "알림내용",
cell: ({ getValue }) => {
const v = getValue() as string;
if (!v) return "—";
return (
<span className="line-clamp-2 max-w-[28rem] text-left" title={v}>
{v}
</span>
);
},
},
{
accessorKey: "recipientNum",
header: "수신자",
cell: ({ getValue }) => (getValue() as string) || "—",
},
{
accessorKey: "dateRslt",
header: "수신일",
cell: ({ getValue }) => formatDateTime(getValue() as string | null),
},
{
accessorKey: "successYn",
header: "결과",
cell: ({ getValue }) => formatResult(getValue() as string | null),
},
];
}
@@ -0,0 +1,258 @@
"use client";
import { useMemo, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import dayjs from "dayjs";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable } from "@/shared/lib";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import {
NtftalkMmtLogHistoryApi,
type NtftalkMmtLogHistorySearchParams,
} from "@/entities/ntftalk";
import { SiteSelect } from "@/entities/site";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { createNtftalkHistoryColumns } from "../model/ntftalk-history-table-columns";
type DateRange = {
from?: Date | null;
to?: Date | null;
};
type PresetPeriod = "" | "3M" | "6M" | "1Y" | "CUSTOM";
function computeDateRangeFromPreset(
preset: Extract<PresetPeriod, "3M" | "6M" | "1Y">,
): DateRange {
const to = dayjs().endOf("day").toDate();
const base = dayjs();
let fromDay: dayjs.Dayjs;
switch (preset) {
case "3M":
fromDay = base.subtract(3, "month");
break;
case "6M":
fromDay = base.subtract(6, "month");
break;
case "1Y":
fromDay = base.subtract(1, "year");
break;
}
return { from: fromDay.startOf("day").toDate(), to };
}
function isDateRangeOverOneYear(range: DateRange): boolean {
if (!range.from) return false;
const oneYearAgo = dayjs().subtract(1, "year").startOf("day");
return dayjs(range.from).isBefore(oneYearAgo);
}
function NtftalkHistoryPage() {
const alert = useAlertStore((state) => state.setMessage);
const [contentInput, setContentInput] = useState("");
const [recipientInput, setRecipientInput] = useState("");
const [presetPeriod, setPresetPeriod] = useState<PresetPeriod>("");
const [dateRange, setDateRange] = useState<DateRange>({
from: null,
to: null,
});
const [resultFilter, setResultFilter] = useState("ALL");
const [systemFilter, setSystemFilter] = useState("ALL");
const [searchParams, setSearchParams] =
useState<NtftalkMmtLogHistorySearchParams>({ sort: "dateMtReport,desc" });
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const listQuery = useQuery({
queryKey: [
ENDPOINT.NTFTALK_SERVICE.MMT_LOG_HISTORY,
searchParams,
pagination,
],
queryFn: () =>
NtftalkMmtLogHistoryApi.getNtftalkMmtLogHistoryPage({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
});
const columns = useMemo(() => createNtftalkHistoryColumns(), []);
const { tableComponent } = useDataTable({
query: listQuery,
tableOptions: {
columns,
state: { pagination },
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
},
dataTableOptions: {
viewPagination: true,
},
});
const buildSearchParamsFromInputs = (): NtftalkMmtLogHistorySearchParams => ({
sort: searchParams.sort ?? "dateMtReport,desc",
content: contentInput.trim() || undefined,
recipientNum: recipientInput.trim() || undefined,
successYn: resultFilter,
system: systemFilter,
dateFrom: dateRange.from
? dayjs(dateRange.from).format("YYYY-MM-DD")
: undefined,
dateTo: dateRange.to ? dayjs(dateRange.to).format("YYYY-MM-DD") : undefined,
});
const handleSearch = (e: React.SubmitEvent) => {
e.preventDefault();
if (isDateRangeOverOneYear(dateRange)) {
alert("조회 기간은 오늘 기준 최대 1년까지만 선택할 수 있습니다.");
return;
}
setPagination((p) => ({ ...p, pageIndex: 0 }));
setSearchParams(buildSearchParamsFromInputs());
};
const handlePresetPeriodChange = (value: PresetPeriod) => {
setPresetPeriod(value);
if (value === "3M" || value === "6M" || value === "1Y") {
setDateRange(computeDateRangeFromPreset(value));
}
};
const handleDateRangeChange = (next: DateRange) => {
setDateRange(next);
setPresetPeriod("CUSTOM");
};
const handleReset = () => {
setContentInput("");
setRecipientInput("");
setPresetPeriod("");
setDateRange({ from: null, to: null });
setResultFilter("ALL");
setSystemFilter("ALL");
setPagination((p) => ({ ...p, pageIndex: 0 }));
setSearchParams({ sort: "dateMtReport,desc" });
};
return (
<div className="card card-border !mt-6 flex flex-col gap-4">
<div className="card card-border">
<form
onSubmit={handleSearch}
className="flex flex-col gap-4 p-2 md:flex-row md:items-end md:justify-between"
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-4">
<div className="flex flex-wrap items-center gap-2">
<Label className="whitespace-nowrap text-label">조회기간</Label>
<Select
value={presetPeriod}
onValueChange={handlePresetPeriodChange}
>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="기간 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="3M">3개월</SelectItem>
<SelectItem value="6M">6개월</SelectItem>
<SelectItem value="1Y">1년</SelectItem>
<SelectItem value="CUSTOM">직접입력</SelectItem>
</SelectContent>
</Select>
<InputDateRangePicker
dateRange={dateRange}
setDateRange={handleDateRangeChange}
disabled={presetPeriod !== "CUSTOM"}
/>
</div>
</div>
<div className="flex flex-wrap gap-6">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-label">알림내용</Label>
<Input
className="w-[16rem]"
value={contentInput}
onChange={(e) => setContentInput(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-label">사용자</Label>
<Input
className="w-[16rem]"
value={recipientInput}
onChange={(e) => setRecipientInput(e.target.value)}
placeholder="수신자 번호"
/>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-label">결과</Label>
<Select value={resultFilter} onValueChange={setResultFilter}>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
<SelectItem value="SUCCESS">성공</SelectItem>
<SelectItem value="FAIL">실패</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-label">시스템</Label>
<SiteSelect
className="w-[16rem]"
value={systemFilter}
onValueChange={(value) => setSystemFilter(value)}
enabledAllSite
/>
</div>
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
<Button
type="button"
color="white"
className="h-[3.2rem]"
onClick={handleReset}
>
초기화
</Button>
</div>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">알림톡 발송 이력</h3>
</div>
<div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</div>
</div>
);
}
export { NtftalkHistoryPage };
@@ -1,6 +1,10 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { NoData } from "@/shared/ui/no-data";
import { NtftalkTempApi } from "@/entities/ntftalk";
import {
Dialog,
DialogContent,
@@ -13,9 +17,6 @@ import {
TableHead,
TableRow,
} from "@common/components";
import { NtftalkTempApi } from "@/entities/ntftalk";
import { ENDPOINT } from "@/shared/config";
import { NoData } from "@/shared/ui/no-data";
interface NtftalkTempDetailDialogProps {
templateCode: string | null;
@@ -2,12 +2,38 @@
import { useCallback, useMemo, useState } from "react";
import { useFieldArray, useForm, useWatch } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
keepPreviousData,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
getCoreRowModel,
type PaginationState,
type RowSelectionState,
useReactTable,
} from "@tanstack/react-table";
import { z } from "zod";
import { AUTH_USER_ID, ENDPOINT } from "@/shared/config";
import {
getCookie,
useAlertStore,
useDataTable,
useMutationWrapper,
} from "@/shared/lib";
import { NoData } from "@/shared/ui/no-data";
import {
NtftalkBiztalkApi,
type NtftalkBiztalkInspectionRequestItem,
type NtftalkTemp,
NtftalkTempApi,
type NtftalkTempSearchParams,
NtftalkVarApi,
type NtftalkVarSearchParams,
} from "@/entities/ntftalk";
import {
Button,
DataTable,
@@ -20,36 +46,14 @@ import {
SelectTrigger,
SelectValue,
} from "@common/components";
import {
getCoreRowModel,
type PaginationState,
type RowSelectionState,
useReactTable,
} from "@tanstack/react-table";
import { z } from "zod";
import {
NtftalkBiztalkApi,
NtftalkTempApi,
NtftalkVarApi,
type NtftalkBiztalkInspectionRequestItem,
type NtftalkTemp,
type NtftalkTempSearchParams,
type NtftalkVarSearchParams,
} from "@/entities/ntftalk";
import { AUTH_USER_ID, ENDPOINT } from "@/shared/config";
import {
getCookie,
useAlertStore,
useDataTable,
useMutationWrapper,
} from "@/shared/lib";
import { NoData } from "@/shared/ui/no-data";
import { createTemplateColumns } from "../model/template-table-columns";
import {
createEmptyVarDraft,
type NtftalkVarGridRow,
} from "../model/ntftalk-var-grid";
import { createTemplateColumns } from "../model/template-table-columns";
import { createVarColumns } from "../model/var-table-columns";
import { NtftalkTempDetailDialog } from "./ntftalk-temp-detail-dialog";
import { NtftalkTempRegistDialog } from "./ntftalk-temp-regist-dialog";
@@ -250,7 +254,7 @@ function NtftalkTempPage() {
messageTypeFilter === ALL ? undefined : messageTypeFilter,
});
const handleSearch = (e: React.FormEvent) => {
const handleSearch = (e: React.SubmitEvent) => {
e.preventDefault();
setPagination((p) => ({ ...p, pageIndex: 0 }));
setSearchParams(buildSearchParamsFromInputs());
@@ -1,8 +1,9 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
keepPreviousData,
useQuery,
@@ -13,10 +14,28 @@ import {
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/shared/ui/form";
import { NoData } from "@/shared/ui/no-data";
import {
NtftalkBiztalkApi,
NtftalkTempApi,
NtftalkVarApi,
type NtftalkVarSearchParams,
} from "@/entities/ntftalk";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
@@ -33,29 +52,13 @@ import {
TableBody,
TableCell,
TableHead,
Textarea,
TableRow,
DialogFooter,
Textarea,
} from "@common/components";
import {
NtftalkBiztalkApi,
NtftalkTempApi,
NtftalkVarApi,
type NtftalkVarSearchParams,
} from "@/entities/ntftalk";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
import { NoData } from "@/shared/ui/no-data";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/shared/ui/form";
import {
ntftalkTempSaveFormSchema,
type NtftalkTempSaveFormData,
ntftalkTempSaveFormSchema,
} from "../model/ntftalk-temp-save-schema";
import { createVarColumns } from "../model/var-table-columns";
+1 -1
View File
@@ -48,7 +48,7 @@ function PopupPage() {
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -77,7 +77,7 @@ function PrivacyLogPage() {
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
+1 -1
View File
@@ -127,7 +127,7 @@ function RecentPage() {
const isSelectedRow = table.getFilteredSelectedRowModel().rows.length > 0;
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setIsDeletePostData(delYn === "Y");
setPagination({
@@ -83,7 +83,7 @@ function MappingRoleDialog({
);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const keyword = formData.get("keyword") as string;
+1 -1
View File
@@ -64,7 +64,7 @@ function RolePage() {
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
+1 -1
View File
@@ -35,7 +35,7 @@ function SitePage() {
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setSearchParams({
...searchParams,
+1 -1
View File
@@ -71,7 +71,7 @@ function SurveyPage() {
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -37,7 +37,7 @@ function TemplatePage() {
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setSearchParams({
...searchParams,
+1 -1
View File
@@ -38,7 +38,7 @@ function TermsPage() {
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
@@ -148,7 +148,7 @@ function UserStatisticsPage() {
setPresetDateRange("");
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const params: ConnectionStatisticsParams = {
keywordType,
+1 -1
View File
@@ -102,7 +102,7 @@ function UsersPage() {
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
+1
View File
@@ -70,6 +70,7 @@ export const ENDPOINT = {
NTFTALK_VAR: "/ntftalk-service/api/v1/ntftalk-var",
NTFTALK_TEMP: "/ntftalk-service/api/v1/ntftalk-temp",
NTFTALK_BIZTALK: "/ntftalk-service/api/v1/ntftalk-biztalk",
MMT_LOG_HISTORY: "/ntftalk-service/api/v1/ata/mmt-log-history",
},
};
@@ -1,20 +1,24 @@
import { InputDatePicker } from "@common/components";
import { useEffect } from "react";
import { InputDatePicker } from "@common/components";
import { useAlertStore } from "../lib";
type DateRange = {
from?: Date;
to?: Date;
from?: Date | null;
to?: Date | null;
};
type InputDateRangePickerProps = {
dateRange: DateRange;
setDateRange: (dateRange: DateRange) => void;
disabled?: boolean;
};
function InputDateRangePicker({
dateRange,
setDateRange,
disabled,
}: InputDateRangePickerProps) {
const alert = useAlertStore((state) => state.setMessage);
@@ -32,6 +36,7 @@ function InputDateRangePicker({
onChange={(date) => {
setDateRange({ ...dateRange, from: date });
}}
disabled={disabled}
/>
~
<InputDatePicker
@@ -39,6 +44,7 @@ function InputDateRangePicker({
onChange={(date) => {
setDateRange({ ...dateRange, to: date });
}}
disabled={disabled}
/>
</>
);
+1 -1
View File
@@ -135,7 +135,7 @@ function PostPage({ bbsId }: { bbsId: string }) {
refetch();
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
@@ -6,7 +6,7 @@ import { LoginApi } from "@/entities/auth";
import "./login.css";
function LoginPage() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
+168 -116
View File
@@ -39,8 +39,10 @@ type TreeNode = {
function CategoryPage() {
const router = useRouter();
const [treeData, setTreeData] = useState<NodeModel<TreeNode>[]>([]);
const [selectedParentNode, setSelectedParentNode] = useState<NodeModel<TreeNode> | null>(null);
const [selectedSubNode, setSelectedSubNode] = useState<NodeModel<TreeNode> | null>(null);
const [selectedParentNode, setSelectedParentNode] =
useState<NodeModel<TreeNode> | null>(null);
const [selectedSubNode, setSelectedSubNode] =
useState<NodeModel<TreeNode> | null>(null);
const [categoryQuery, setSearchText] = useState<CategoryParams>({
clsfSystmNm: "",
});
@@ -48,7 +50,9 @@ function CategoryPage() {
const [loading, setLoading] = useState(false);
const { setMessage: showAlert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const [dialog, setDialog] = useState<null | { type: "add" | "edit" | "delete" }>(null);
const [dialog, setDialog] = useState<null | {
type: "add" | "edit" | "delete";
}>(null);
const userUnqId = useUserStore((state) => state.userInfo?.userUnqId || "");
const userInfo = useUserStore((state) => state.userInfo);
const [expandedIds, setExpandedIds] = useState<(string | number)[]>([]);
@@ -57,7 +61,8 @@ function CategoryPage() {
const categoryNameInputRef = useRef<HTMLInputElement>(null);
const [showCategoryNameError, setShowCategoryNameError] = useState(false);
const [isAddingMode, setIsAddingMode] = useState(false);
const [addingParentNode, setAddingParentNode] = useState<NodeModel<TreeNode> | null>(null);
const [addingParentNode, setAddingParentNode] =
useState<NodeModel<TreeNode> | null>(null);
const { data: categoryList, refetch } = useQuery({
queryKey: [ENDPOINT.DC_SERVICE.CATEGORY],
@@ -70,20 +75,18 @@ function CategoryPage() {
refetch();
}, [refetch]);
const handleDrop = (newTreeData: NodeModel<TreeNode>[]) => {
setTreeData(newTreeData);
};
useEffect(() => {
const treeElement = document.querySelector('.tree-container');
const treeElement = document.querySelector(".tree-container");
if (treeElement) {
expandedIds.forEach(id => {
expandedIds.forEach((id) => {
const nodeElement = treeElement.querySelector(`[data-id="${id}"]`);
if (nodeElement) {
const toggleButton = nodeElement.querySelector('.toggle-button');
if (toggleButton && !nodeElement.classList.contains('expanded')) {
const toggleButton = nodeElement.querySelector(".toggle-button");
if (toggleButton && !nodeElement.classList.contains("expanded")) {
(toggleButton as HTMLElement).click();
}
}
@@ -91,7 +94,7 @@ function CategoryPage() {
}
}, [expandedIds]);
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
const handleSearch = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (!searchTerm.trim()) {
showAlert("검색어를 입력해주세요");
@@ -99,7 +102,9 @@ function CategoryPage() {
}
const searchResults = treeData.filter((node) =>
(node.text ?? "").toLowerCase().includes((searchTerm ?? "").toLowerCase())
(node.text ?? "")
.toLowerCase()
.includes((searchTerm ?? "").toLowerCase()),
);
if (searchResults.length === 0) {
@@ -138,7 +143,7 @@ function CategoryPage() {
setNewCategoryName("");
} else {
setSelectedSubNode(node);
const parentNode = treeData.find(n => n.id === node.parent) || null;
const parentNode = treeData.find((n) => n.id === node.parent) || null;
setSelectedParentNode(parentNode);
setNewCategoryName(node.text || "");
@@ -147,7 +152,7 @@ function CategoryPage() {
const newExpandedIds = new Set(expandedIds);
while (parentId && parentId !== 0 && parentId !== "0") {
newExpandedIds.add(parentId);
const parentNode = treeData.find(n => n.id === parentId);
const parentNode = treeData.find((n) => n.id === parentId);
if (parentNode) {
parentId = parentNode.parent;
} else {
@@ -230,7 +235,7 @@ function CategoryPage() {
showAlert("수정할 분류 구조를 선택해주세요.");
return;
}
setLoading(true);
try {
await CategoryApi.updateCategory({
@@ -283,7 +288,7 @@ function CategoryPage() {
showAlert("상위 분류를 선택해주세요.");
return;
}
setLoading(true);
try {
await CategoryApi.createCategory({
@@ -362,7 +367,7 @@ function CategoryPage() {
// 백엔드에서 트리 구조로 반환하므로 flat 구조로 변환
const flattenTree = (nodes: any[], parentId = "0"): any[] => {
const result: any[] = [];
nodes.forEach((node) => {
// 현재 노드를 flat 구조로 추가
result.push({
@@ -371,65 +376,67 @@ function CategoryPage() {
droppable: node.droppAble,
text: node.clsfSystmNm,
});
// 자식 노드들이 있으면 재귀적으로 처리
if (node.children && node.children.length > 0) {
result.push(...flattenTree(node.children, node.clsfSystmSn));
}
});
return result;
};
const mappedData = flattenTree(categoryList);
// 트리 데이터를 영문순서, 한글순서로 정렬 (각 레벨별로)
const sortTreeData = (nodes: typeof mappedData): typeof mappedData => {
// 부모별로 그룹화
const groupedByParent = new Map<string, typeof mappedData>();
nodes.forEach(node => {
nodes.forEach((node) => {
const parent = node.parent;
if (!groupedByParent.has(parent)) {
groupedByParent.set(parent, []);
}
groupedByParent.get(parent)!.push(node);
});
// 각 그룹 내에서 정렬 (최상위 부모부터)
const sortedGroups = Array.from(groupedByParent.entries()).sort(([parentA], [parentB]) => {
// 최상위 부모("0")를 먼저 정렬
if (parentA === "0" && parentB !== "0") return -1;
if (parentA !== "0" && parentB === "0") return 1;
return 0;
});
const sortedGroups = Array.from(groupedByParent.entries()).sort(
([parentA], [parentB]) => {
// 최상위 부모("0")를 먼저 정렬
if (parentA === "0" && parentB !== "0") return -1;
if (parentA !== "0" && parentB === "0") return 1;
return 0;
},
);
sortedGroups.forEach(([parent, children]) => {
children.sort((a, b) => {
const textA = a.text || "";
const textB = b.text || "";
// 영문이 먼저 오도록 정렬
const isEnglishA = /^[a-zA-Z]/.test(textA);
const isEnglishB = /^[a-zA-Z]/.test(textB);
if (isEnglishA && !isEnglishB) return -1;
if (!isEnglishA && isEnglishB) return 1;
// 같은 언어 내에서는 알파벳/가나다순 정렬
return textA.localeCompare(textB, ['ko', 'en']);
return textA.localeCompare(textB, ["ko", "en"]);
});
});
// 정렬된 결과를 다시 합치기 (순서 보장)
const sortedData: typeof mappedData = [];
sortedGroups.forEach(([parent, children]) => {
sortedData.push(...children);
});
return sortedData;
};
// 각 레벨별로 정렬
const sortedData = sortTreeData(mappedData);
setTreeData(sortedData);
@@ -443,8 +450,12 @@ function CategoryPage() {
const filterTree = (nodes: NodeModel<TreeNode>[]) => {
return nodes.reduce((acc: NodeModel<TreeNode>[], node) => {
const nodeMatches = (node.text ?? "").toLowerCase().includes((searchTerm ?? "").toLowerCase());
const filteredChildren = node.droppable ? filterTree(treeData.filter(n => n.parent === node.id)) : [];
const nodeMatches = (node.text ?? "")
.toLowerCase()
.includes((searchTerm ?? "").toLowerCase());
const filteredChildren = node.droppable
? filterTree(treeData.filter((n) => n.parent === node.id))
: [];
if (nodeMatches || filteredChildren.length > 0) {
acc.push(node);
@@ -466,14 +477,16 @@ function CategoryPage() {
setAddingParentNode(null);
};
useEffect(() => {
}, [userInfo, userUnqId]);
useEffect(() => {}, [userInfo, userUnqId]);
return (
<PageLayout pageTitle="자료관 분류 관리">
<div className="card !mb-0 flex flex-col !min-h-[calc(100vh-16.8rem)]">
<div className="card !mb-0 flex !min-h-[calc(100vh-16.8rem)] flex-col">
<div className="card card-border">
<form onSubmit={handleSearch} className="flex items-center gap-2 p-2 mb-2">
<form
onSubmit={handleSearch}
className="mb-2 flex items-center gap-2 p-2"
>
<label htmlFor="category-search" className="mr-2 text-label">
분류명
</label>
@@ -484,50 +497,55 @@ function CategoryPage() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Button className="h-[3.6rem] rounded-sm text-[1.3rem]" type="submit">
<Button
className="h-[3.6rem] rounded-sm text-[1.3rem]"
type="submit"
>
검색
</Button>
<Button
type="button"
className="h-[3.6rem] !bg-gray-600 hover:!bg-gray-600 flex items-center gap-2"
<Button
type="button"
className="flex h-[3.6rem] items-center gap-2 !bg-gray-600 hover:!bg-gray-600"
onClick={handleReset}
>
<RotateCcw className="w-4 h-4" />
<RotateCcw className="h-4 w-4" />
초기화
</Button>
</form>
</div>
<div className="flex flex-1 gap-2 min-h-0">
<div className="card card-border w-[60%] flex flex-col">
<div className="flex min-h-0 flex-1 gap-2">
<div className="card card-border flex w-[60%] flex-col">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">자료관 분류 트리</h3>
<h3 className="text-2xl font-medium text-label">
자료관 분류 트리
</h3>
</div>
<div
className="p-4 flex-1 min-h-0 max-h-[60vh] overflow-y-auto tree-container"
<div
className="tree-container max-h-[60vh] min-h-0 flex-1 overflow-y-auto p-4"
onDragOver={(e) => {
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
}}
onDrop={(e) => {
// 파일 드롭은 방지하되, 트리 내부 드롭은 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
}}
onDragEnter={(e) => {
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
}}
onDragLeave={(e) => {
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
@@ -543,41 +561,50 @@ function CategoryPage() {
insertDroppableFirst={false}
canDrop={() => false}
render={(node, { depth, isOpen, onToggle }) => {
const isExpanded = expandedIds.map(String).includes(String(node.id));
const isSearchResult = searchTerm && (node.text ?? "").toLowerCase().includes(searchTerm.toLowerCase());
const isExpanded = expandedIds
.map(String)
.includes(String(node.id));
const isSearchResult =
searchTerm &&
(node.text ?? "")
.toLowerCase()
.includes(searchTerm.toLowerCase());
return (
<div
ref={el => {
ref={(el) => {
if (isSearchResult) {
searchResultRefs.current = [...searchResultRefs.current, el];
searchResultRefs.current = [
...searchResultRefs.current,
el,
];
}
}}
data-id={node.id}
onDragOver={(e) => {
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
}}
onDrop={(e) => {
// 파일 드롭은 방지하되, 트리 내부 드롭은 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
}}
onDragEnter={(e) => {
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
}}
onDragLeave={(e) => {
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
@@ -586,35 +613,46 @@ function CategoryPage() {
handleSelect(node);
if (node.droppable) {
onToggle();
setExpandedIds(prev => {
setExpandedIds((prev) => {
const idStr = String(node.id);
const prevStr = prev.map(String);
const isCurrentlyExpanded = prevStr.includes(idStr);
const isCurrentlyExpanded =
prevStr.includes(idStr);
if (isCurrentlyExpanded) {
// 닫는 동작: 하위 노드에 선택된 노드가 있으면 선택 해제
// 하위 노드 id 목록 구하기
const getDescendantIds = (parentId: string | number): (string | number)[] => {
const children = treeData.filter(n => n.parent === parentId);
let all = children.map(n => n.id);
children.forEach(child => {
all = all.concat(getDescendantIds(child.id));
const getDescendantIds = (
parentId: string | number,
): (string | number)[] => {
const children = treeData.filter(
(n) => n.parent === parentId,
);
let all = children.map((n) => n.id);
children.forEach((child) => {
all = all.concat(
getDescendantIds(child.id),
);
});
return all;
};
const descendants = getDescendantIds(node.id);
if (
selectedSubNode &&
(descendants.includes(selectedSubNode.id) || selectedSubNode.id === node.id)
(descendants.includes(selectedSubNode.id) ||
selectedSubNode.id === node.id)
) {
setSelectedSubNode(null);
setSelectedParentNode(null);
setNewCategoryName("");
}
return prevStr.filter(id => id !== idStr);
return prevStr.filter((id) => id !== idStr);
} else {
// 여는 동작: 자동 선택
setSelectedSubNode(node);
setSelectedParentNode(treeData.find(n => n.id === node.parent) || null);
setSelectedParentNode(
treeData.find((n) => n.id === node.parent) ||
null,
);
setNewCategoryName(node.text || "");
return [...prevStr, idStr];
}
@@ -622,68 +660,72 @@ function CategoryPage() {
}
}}
style={{ paddingLeft: `${depth * 16}px` }}
className={`cursor-pointer py-3 border-b border-gray-200 flex items-center transition-colors
${String(selectedSubNode?.id) === String(node.id)
className={`flex cursor-pointer items-center border-b border-gray-200 py-3 transition-colors ${
String(selectedSubNode?.id) === String(node.id)
? "bg-blue-100"
: isSearchResult
? "bg-sky-100"
: "hover:bg-gray-100"}
${isExpanded ? 'expanded' : ''}
`}
: "hover:bg-gray-100"
} ${isExpanded ? "expanded" : ""} `}
>
{node.droppable && (
<span
onDragOver={(e) => {
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
}}
onDrop={(e) => {
// 파일 드롭은 방지하되, 트리 내부 드롭은 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
}}
onDragEnter={(e) => {
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
}}
onDragLeave={(e) => {
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.stopPropagation();
}
}}
onClick={e => {
onClick={(e) => {
e.stopPropagation();
onToggle();
setExpandedIds(prev => {
setExpandedIds((prev) => {
const idStr = String(node.id);
const prevStr = prev.map(String);
const isCurrentlyExpanded = prevStr.includes(idStr);
const isCurrentlyExpanded =
prevStr.includes(idStr);
if (isCurrentlyExpanded) {
return prevStr.filter(id => id !== idStr);
return prevStr.filter((id) => id !== idStr);
} else {
setSelectedSubNode(node);
setSelectedParentNode(treeData.find(n => n.id === node.parent) || null);
setSelectedParentNode(
treeData.find(
(n) => n.id === node.parent,
) || null,
);
setNewCategoryName(node.text || "");
return [...prevStr, idStr];
}
});
}}
className="mr-2 flex items-center justify-center cursor-pointer toggle-button"
className="toggle-button mr-2 flex cursor-pointer items-center justify-center"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
<ChevronRight className="h-4 w-4 text-gray-400" />
)}
</span>
)}
@@ -695,36 +737,46 @@ function CategoryPage() {
</DndProvider>
</div>
</div>
<div className="card card-border flex-1 flex flex-col w-[60%] overflow-y-auto">
<div className="card card-border flex w-[60%] flex-1 flex-col overflow-y-auto">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">분류 추가</h3>
</div>
<div className="p-4 flex-1 flex flex-col">
<div className="flex flex-1 flex-col p-4">
<div>
<div className="mb-[10px]">
<label className="block text-md font-medium text-label mb-1">
<label className="text-md mb-1 block font-medium text-label">
상위 항목
</label>
<input
type="text"
className="w-full rounded border px-3 py-2 h-[3.2rem] !text-[1.3rem] bg-gray-200 cursor-not-allowed"
className="h-[3.2rem] w-full cursor-not-allowed rounded border bg-gray-200 px-3 py-2 !text-[1.3rem]"
readOnly
placeholder="상위 분류를 선택해주세요"
value={isAddingMode ? (addingParentNode?.text || "") : (selectedParentNode?.text || "")}
value={
isAddingMode
? addingParentNode?.text || ""
: selectedParentNode?.text || ""
}
/>
</div>
<div className="mb-[10px]">
<label className="block text-md font-medium text-label mb-1 flex items-center">
<label className="text-md mb-1 block flex items-center font-medium text-label">
<span className="text-red-500">*</span> 분류 이름
</label>
<input
ref={categoryNameInputRef}
type="text"
className={`w-full rounded border px-3 py-2 h-[3.2rem] !text-[1.3rem] ${
showCategoryNameError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
className={`h-[3.2rem] w-full rounded border px-3 py-2 !text-[1.3rem] ${
showCategoryNameError
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
}`}
placeholder={isAddingMode ? "새로 추가할 분류 이름을 입력해주세요" : "분류 이름을 입력해주세요"}
placeholder={
isAddingMode
? "새로 추가할 분류 이름을 입력해주세요"
: "분류 이름을 입력해주세요"
}
value={newCategoryName}
onChange={(e) => {
setNewCategoryName(e.target.value);
@@ -735,17 +787,17 @@ function CategoryPage() {
readOnly={false}
/>
{showCategoryNameError && (
<p className="text-red-600 text-sm mt-2"></p>
<p className="mt-2 text-sm text-red-600"></p>
)}
</div>
</div>
<div className="flex-1 flex flex-col justify-end">
<div className="flex justify-end mt-10 gap-2">
<div className="flex flex-1 flex-col justify-end">
<div className="mt-10 flex justify-end gap-2">
{isAddingMode ? (
<>
<button
type="button"
className="bg-primary text-white px-8 py-2 rounded-sm hover:bg-primary/80 text-md h-[3.2rem]"
className="hover:bg-primary/80 text-md h-[3.2rem] rounded-sm bg-primary px-8 py-2 text-white"
onClick={() => setDialog({ type: "add" })}
disabled={loading}
>
@@ -753,7 +805,7 @@ function CategoryPage() {
</button>
<button
type="button"
className="bg-gray-500 text-white px-8 py-2 rounded-sm hover:bg-gray-600 text-md h-[3.2rem]"
className="text-md h-[3.2rem] rounded-sm bg-gray-500 px-8 py-2 text-white hover:bg-gray-600"
onClick={() => {
setIsAddingMode(false);
setAddingParentNode(null);
@@ -771,7 +823,7 @@ function CategoryPage() {
<>
<button
type="button"
className="bg-primary text-white px-8 py-2 rounded-sm hover:bg-primary/80 text-md h-[3.2rem]"
className="hover:bg-primary/80 text-md h-[3.2rem] rounded-sm bg-primary px-8 py-2 text-white"
onClick={handleAddCategory}
disabled={loading}
>
@@ -779,7 +831,7 @@ function CategoryPage() {
</button>
<button
type="button"
className="bg-gray-600 text-white px-8 py-2 rounded-sm hover:bg-gray-700 text-md h-[3.2rem]"
className="text-md h-[3.2rem] rounded-sm bg-gray-600 px-8 py-2 text-white hover:bg-gray-700"
onClick={handleEditCategory}
disabled={loading}
>
@@ -787,7 +839,7 @@ function CategoryPage() {
</button>
<button
type="button"
className="bg-red-600 text-white px-8 py-2 rounded-sm hover:bg-red-700 text-md h-[3.2rem] cursor-pointer"
className="text-md h-[3.2rem] cursor-pointer rounded-sm bg-red-600 px-8 py-2 text-white hover:bg-red-700"
onClick={handleDeleteCategory}
disabled={!selectedSubNode || loading}
>
@@ -807,10 +859,10 @@ function CategoryPage() {
dialog?.type === "add"
? "분류를 추가하시겠습니까?"
: dialog?.type === "edit"
? "분류를 수정하시겠습니까?"
: dialog?.type === "delete"
? "분류를 삭제하시겠습니까?"
: ""
? "분류를 수정하시겠습니까?"
: dialog?.type === "delete"
? "분류를 삭제하시겠습니까?"
: ""
}
loading={loading}
onConfirm={handleDialogConfirm}
@@ -820,4 +872,4 @@ function CategoryPage() {
);
}
export { CategoryPage };
export { CategoryPage };
+82 -62
View File
@@ -5,12 +5,15 @@ import { Button, Input } from "@common/components";
import { PageLayout } from "@/shared/ui/page-layout";
import { UserCircle, Bot } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { askQuestion, downloadPdf } from "@/entities/datalibrary/api/flask-service";
import {
askQuestion,
downloadPdf,
} from "@/entities/datalibrary/api/flask-service";
interface Message {
role: "user" | "bot";
text: string;
sources?: Array<{
sources?: Array<{
pdf_path: string;
original_filename: string;
page: number;
@@ -39,39 +42,45 @@ function ChatBotPage() {
// RAG 요청 함수
const handleRagRequest = async (question: string) => {
setLoading(true);
// "봇이 답변을 생성중입니다..." 메시지를 먼저 추가
setMessages(prev => [...prev, { role: "bot", text: "봇이 답변을 생성중입니다...", sources: [] }]);
setMessages((prev) => [
...prev,
{ role: "bot", text: "봇이 답변을 생성중입니다...", sources: [] },
]);
try {
const data = await askQuestion(question, sessionId);
const answer = data.answer || data.response || '답변을 찾을 수 없습니다.';
const answer = data.answer || data.response || "답변을 찾을 수 없습니다.";
const sources = data.sources || [];
// session_id가 응답에 포함되어 있으면 저장
if (data.session_id) {
setSessionId(data.session_id);
}
// 답변을 한 글자씩 실시간으로 표시
for (let i = 0; i <= answer.length; i++) {
await new Promise(resolve => setTimeout(resolve, 30)); // 30ms 딜레이
setMessages(prev => {
await new Promise((resolve) => setTimeout(resolve, 30)); // 30ms 딜레이
setMessages((prev) => {
const newMessages = [...prev];
// 마지막 메시지(봇 메시지)를 실제 답변으로 교체
newMessages[newMessages.length - 1] = {
role: "bot",
text: answer.substring(0, i),
sources: sources // 모든 출처 표시
newMessages[newMessages.length - 1] = {
role: "bot",
text: answer.substring(0, i),
sources: sources, // 모든 출처 표시
};
return newMessages;
});
}
} catch (error) {
console.error('RAG 요청 오류:', error);
setMessages(prev => {
console.error("RAG 요청 오류:", error);
setMessages((prev) => {
const newMessages = [...prev];
newMessages[newMessages.length - 1] = { role: "bot", text: '질문에 대한 답변을 생성하는 중 오류가 발생했습니다.' };
newMessages[newMessages.length - 1] = {
role: "bot",
text: "질문에 대한 답변을 생성하는 중 오류가 발생했습니다.",
};
return newMessages;
});
} finally {
@@ -84,7 +93,7 @@ function ChatBotPage() {
try {
const blob = await downloadPdf(pdfPath, fileName);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
@@ -92,87 +101,98 @@ function ChatBotPage() {
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('파일 다운로드 오류:', error);
alert('파일 다운로드 중 오류가 발생했습니다.');
console.error("파일 다운로드 오류:", error);
alert("파일 다운로드 중 오류가 발생했습니다.");
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (!input.trim() || loading) return;
const question = input.trim();
setMessages(prev => [...prev, { role: "user", text: question }]);
setMessages((prev) => [...prev, { role: "user", text: question }]);
setInput("");
// RAG 요청
handleRagRequest(question);
};
return (
<PageLayout pageTitle="하수도 GPT">
<div className="card card-border mt-6 flex flex-col h-[calc(100vh-16.8rem)]">
<div className="card card-border mt-6 flex h-[calc(100vh-16.8rem)] flex-col">
<div className="card card-header flex-shrink-0">
<h3 className="text-2xl font-medium text-label">하수도 GPT</h3>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3 min-h-0">
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
{messages.map((msg, index) => (
<div
key={index}
className={`flex items-end gap-2 max-w-[75%] ${
className={`flex max-w-[75%] items-end gap-2 ${
msg.role === "user"
? "flex-row-reverse self-end ml-auto"
: "self-start mr-auto"
? "ml-auto flex-row-reverse self-end"
: "mr-auto self-start"
}`}
>
<span className="shrink-0 flex items-center">
<span className="flex shrink-0 items-center">
{msg.role === "user" ? (
<UserCircle className="w-8 h-8 text-blue-400" />
<UserCircle className="h-8 w-8 text-blue-400" />
) : (
<Bot className="w-8 h-8 text-gray-400" />
<Bot className="h-8 w-8 text-gray-400" />
)}
</span>
<span
className={`p-3 rounded-md text-2xl whitespace-pre-wrap ${
msg.role === "user"
? "bg-blue-100"
: "bg-gray-100"
className={`whitespace-pre-wrap rounded-md p-3 text-2xl ${
msg.role === "user" ? "bg-blue-100" : "bg-gray-100"
}`}
style={{ maxWidth: "calc(100% - 2.5rem)" }}
>
{msg.text}
{/* 출처 표시: bot 답변에만 */}
{msg.role === "bot" && msg.sources && msg.sources.length > 0 && (
<div className="mt-2 text-lg text-gray-500 space-y-1">
<div className="font-semibold flex items-center gap-1">
<span role="img" aria-label="출처">🔗</span> 출처
{msg.role === "bot" &&
msg.sources &&
msg.sources.length > 0 && (
<div className="mt-2 space-y-1 text-lg text-gray-500">
<div className="flex items-center gap-1 font-semibold">
<span role="img" aria-label="출처">
🔗
</span>{" "}
출처
</div>
<ul className="ml-2 list-inside list-disc">
{msg.sources.map((src, i) => (
<li key={i}>
<button
onClick={() =>
handleSourceDownload(
src.pdf_path,
src.original_filename,
)
}
className="text-left underline hover:text-blue-600"
>
{src.original_filename} (페이지 {src.page})
</button>
</li>
))}
</ul>
</div>
<ul className="list-disc list-inside ml-2">
{msg.sources.map((src, i) => (
<li key={i}>
<button
onClick={() => handleSourceDownload(src.pdf_path, src.original_filename)}
className="underline hover:text-blue-600 text-left"
>
{src.original_filename} (페이지 {src.page})
</button>
</li>
))}
</ul>
</div>
)}
)}
</span>
</div>
))}
{/* 로딩 상태 표시 - 실시간 답변 생성 중에는 표시하지 않음 */}
{loading && messages.length === 0 && (
<div className="flex items-end gap-2 max-w-[75%] self-start mr-auto">
<span className="shrink-0 flex items-center">
<Bot className="w-8 h-8 text-gray-400" />
<div className="mr-auto flex max-w-[75%] items-end gap-2 self-start">
<span className="flex shrink-0 items-center">
<Bot className="h-8 w-8 text-gray-400" />
</span>
<span className="p-3 rounded-md text-2xl bg-gray-100" style={{ maxWidth: "calc(100% - 2.5rem)" }}>
<span
className="rounded-md bg-gray-100 p-3 text-2xl"
style={{ maxWidth: "calc(100% - 2.5rem)" }}
>
답변을 생성하고 있습니다...
</span>
</div>
@@ -181,10 +201,10 @@ function ChatBotPage() {
<form
onSubmit={handleSubmit}
className="flex items-center gap-2 p-4 border-t flex-shrink-0"
className="flex flex-shrink-0 items-center gap-2 border-t p-4"
>
<Input
className="flex-1 h-[3.2rem] !text-[1.5rem]"
className="h-[3.2rem] flex-1 !text-[1.5rem]"
placeholder="질문을 입력하세요..."
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -199,4 +219,4 @@ function ChatBotPage() {
);
}
export { ChatBotPage };
export { ChatBotPage };
@@ -53,7 +53,9 @@ function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
// ✅ 유효성/서버 에러 표시용
const [errors, setErrors] = useState<Partial<Record<FieldKey, string>>>({});
const [touched, setTouched] = useState<Partial<Record<FieldKey, boolean>>>({});
const [touched, setTouched] = useState<Partial<Record<FieldKey, boolean>>>(
{},
);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
@@ -99,7 +101,8 @@ function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
if (!b) next.bizNo = "사업자등록번호를 입력해주세요.";
else if (!isBizNoValid(b))
next.bizNo = "사업자등록번호 형식이 올바르지 않습니다. (예: 123-45-67890)";
next.bizNo =
"사업자등록번호 형식이 올바르지 않습니다. (예: 123-45-67890)";
if (!noWorker && !w) next.workerNo = "종사자등록번호를 입력해주세요.";
@@ -115,7 +118,7 @@ function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
const markAllTouched = () =>
setTouched({ bizNo: true, workerNo: true, pw: true });
const submit = async (e: React.FormEvent) => {
const submit = async (e: React.SubmitEvent) => {
e.preventDefault();
markAllTouched();
@@ -138,7 +141,10 @@ function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
// --- 데모: 비밀번호가 "1234"면 성공 처리(원하면 제거) ---
const serverOk = pw.trim() === "1234";
if (!serverOk) {
setErrors((p) => ({ ...p, pw: "잘못된 비밀번호입니다. (데모: 1234 입력 시 성공)" }));
setErrors((p) => ({
...p,
pw: "잘못된 비밀번호입니다. (데모: 1234 입력 시 성공)",
}));
setTouched((p) => ({ ...p, pw: true }));
return;
}
@@ -210,7 +216,9 @@ function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
validate();
}}
placeholder="사업자등록번호를 입력하세요. (예: 123-45-67890)"
className={inputCls({ hasError: !!(touched.bizNo && errors.bizNo) })}
className={inputCls({
hasError: !!(touched.bizNo && errors.bizNo),
})}
/>
{touched.bizNo && errors.bizNo && (
<p className="mt-3 text-[14px] font-medium text-red-500">
@@ -251,7 +259,8 @@ function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
placeholder="종사자등록번호를 입력하세요."
className={inputCls({
disabled: noWorker,
hasError: !noWorker && !!(touched.workerNo && errors.workerNo),
hasError:
!noWorker && !!(touched.workerNo && errors.workerNo),
})}
/>
@@ -279,7 +288,9 @@ function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
validate();
}}
placeholder="비밀번호를 입력하세요."
className={inputCls({ hasError: !!(touched.pw && errors.pw) })}
className={inputCls({
hasError: !!(touched.pw && errors.pw),
})}
/>
{touched.pw && errors.pw && (
<p className="mt-3 text-[14px] font-medium text-red-500">
@@ -305,7 +316,9 @@ function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
{/* 비밀번호 초기화 요청 */}
<button
type="button"
onClick={() => alert("TODO: 비밀번호 초기화 요청 화면/기능 연결")}
onClick={() =>
alert("TODO: 비밀번호 초기화 요청 화면/기능 연결")
}
className="mx-auto block text-[14px] text-[#767676] hover:text-gray-900"
>
비밀번호 초기화 요청
@@ -439,7 +452,10 @@ export default function TopNav() {
) : (
<>
<span className="text-base text-gray-700">
<strong className="text-xl text-emerald-600">{userName}</strong>님 환영합니다.
<strong className="text-xl text-emerald-600">
{userName}
</strong>
님 환영합니다.
</span>
<button
type="button"
@@ -448,13 +464,13 @@ export default function TopNav() {
>
로그아웃
<span aria-hidden>
<Image
src="/images/landing/icon_logout.png"
alt="로그아웃 아이콘"
width={24}
height={24}
className="h-6 w-6 object-contain"
/>
<Image
src="/images/landing/icon_logout.png"
alt="로그아웃 아이콘"
width={24}
height={24}
className="h-6 w-6 object-contain"
/>
</span>
</button>
</>
+28 -12
View File
@@ -4,7 +4,7 @@ import { useEffect, useId, useState } from "react";
import { useRouter } from "next/navigation";
import { useDmStore } from "@/app/store";
import type { DemandCompType } from "@common/business_components/ui/sdmInsertForm/form";
import { sha512 } from 'js-sha512';
import { sha512 } from "js-sha512";
type LoginModalProps = {
open: boolean;
@@ -21,13 +21,19 @@ function isBizNoValid(v: string) {
}
function inputCls(opts: { hasError?: boolean; disabled?: boolean }) {
const base = "h-[48px] w-full rounded-[4px] border px-5 text-[18px] outline-none";
const base =
"h-[48px] w-full rounded-[4px] border px-5 text-[18px] outline-none";
if (opts.disabled) return `${base} border-gray-200 bg-gray-50 text-gray-400`;
if (opts.hasError) return `${base} border-red-500 ring-1 ring-red-400 focus:border-red-500`;
if (opts.hasError)
return `${base} border-red-500 ring-1 ring-red-400 focus:border-red-500`;
return `${base} border-gray-300 focus:border-sky-400`;
}
export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps) {
export default function LoginModal({
open,
onClose,
onSuccess,
}: LoginModalProps) {
const titleId = useId();
const router = useRouter();
const { actionLogin } = useDmStore((state) => state);
@@ -39,7 +45,9 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
// ✅ 유효성/서버 에러 표시용
const [errors, setErrors] = useState<Partial<Record<FieldKey, string>>>({});
const [touched, setTouched] = useState<Partial<Record<FieldKey, boolean>>>({});
const [touched, setTouched] = useState<Partial<Record<FieldKey, boolean>>>(
{},
);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
@@ -64,7 +72,8 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
}, [open, onClose]);
useEffect(() => {
if (noWorker) { // 종사업자번호 해당없음
if (noWorker) {
// 종사업자번호 해당없음
setWorkerNo(""); // 종사업자번호
setErrors((p) => {
const nextErrors = { ...p };
@@ -122,7 +131,7 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
const markAllTouched = () =>
setTouched({ bizNo: true, workerNo: true, pw: true });
const submit = async (e: React.FormEvent) => {
const submit = async (e: React.SubmitEvent) => {
e.preventDefault();
markAllTouched();
@@ -150,8 +159,10 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
};
const initPassword = () => {
alert("관리자(031-0000-0000)에게 비밀번호 초기화 요청을 유선으로 하시기 바랍니다.");
}
alert(
"관리자(031-0000-0000)에게 비밀번호 초기화 요청을 유선으로 하시기 바랍니다.",
);
};
return (
<div className="fixed inset-0 z-[100]">
@@ -212,7 +223,9 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
validate();
}}
placeholder="사업자등록번호를 입력하세요. (예: 123-45-67890)"
className={inputCls({ hasError: !!(touched.bizNo && errors.bizNo) })}
className={inputCls({
hasError: !!(touched.bizNo && errors.bizNo),
})}
/>
{touched.bizNo && errors.bizNo && (
<p className="mt-3 text-[14px] font-medium text-red-500">
@@ -253,7 +266,8 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
placeholder="종사자등록번호를 입력하세요."
className={inputCls({
disabled: noWorker,
hasError: !noWorker && !!(touched.workerNo && errors.workerNo),
hasError:
!noWorker && !!(touched.workerNo && errors.workerNo),
})}
/>
@@ -281,7 +295,9 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
validate();
}}
placeholder="비밀번호를 입력하세요."
className={inputCls({ hasError: !!(touched.pw && errors.pw) })}
className={inputCls({
hasError: !!(touched.pw && errors.pw),
})}
/>
{touched.pw && errors.pw && (
<p className="mt-3 text-[14px] font-medium text-red-500">
+120 -74
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import useSWR from 'swr';
import useSWR from "swr";
import {
Button,
@@ -12,20 +12,29 @@ import {
SelectContent,
SelectItem,
SelectValue,
Checkbox
}
from "@common/components";
Checkbox,
} from "@common/components";
import { useAlertStore } from "@/shared/lib";
import { type SearchFormProps, type SearchFormValues, defaultSearchFormProps } from "../type/SearchForm.type";
import {
type SearchFormProps,
type SearchFormValues,
defaultSearchFormProps,
} from "../type/SearchForm.type";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import type { ApiResponse, SearchCondition, Sgg } from "@/entities/statistics/model/statistics-interface";
import type {
ApiResponse,
SearchCondition,
Sgg,
} from "@/entities/statistics/model/statistics-interface";
const getSidoListFetcher = async (): Promise<ApiResponse<SearchCondition>> => {
return statisticsAPi.getSidoList();
};
const getSggListFetcher = async (key: [string, string]): Promise<ApiResponse<Sgg[]>> => {
const getSggListFetcher = async (
key: [string, string],
): Promise<ApiResponse<Sgg[]>> => {
return statisticsAPi.getSggList(key[1]);
};
@@ -46,7 +55,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== 'success') {
if (data.status !== "success") {
alert(`시도 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
}
@@ -55,23 +64,21 @@ const SearchForm = (props: SearchFormProps = {}) => {
alert(`시도 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
},
}
},
);
const [values, setValues] = useState<SearchFormValues>({
sido: props.defaultValues?.sido || '',
gugun: props.defaultValues?.gugun || '',
sido: props.defaultValues?.sido || "",
gugun: props.defaultValues?.gugun || "",
year: props.defaultValues?.year || defaultYear.toString(),
order: props.defaultValues?.order || '',
fctName: props.defaultValues?.fctName || '',
bsnmCd: props.defaultValues?.bsnmCd || '',
order: props.defaultValues?.order || "",
fctName: props.defaultValues?.fctName || "",
bsnmCd: props.defaultValues?.bsnmCd || "",
isDetail: false,
});
const sggKey: [string, string] | null =
values.sido && values.sido !== "ALL"
? ["sgg-list", values.sido]
: null;
values.sido && values.sido !== "ALL" ? ["sgg-list", values.sido] : null;
const { data: sggList } = useSWR<ApiResponse<Sgg[]>>(
sggKey,
getSggListFetcher,
@@ -82,7 +89,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== 'success') {
if (data.status !== "success") {
alert(`시군구 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
}
@@ -91,23 +98,23 @@ const SearchForm = (props: SearchFormProps = {}) => {
alert(`시군구 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
},
}
},
);
const sggOptions = sggList?.data ?? [];
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
props.onSubmit?.(values);
};
const handleReset = () => {
const resetValues: SearchFormValues = {
sido: '',
gugun: '',
sido: "",
gugun: "",
year: String(defaultYear),
order: '',
fctName: '',
bsnmCd: '',
order: "",
fctName: "",
bsnmCd: "",
isDetail: false,
};
setValues(resetValues);
@@ -115,20 +122,23 @@ const SearchForm = (props: SearchFormProps = {}) => {
};
const handleSidoChange = (value: string) => {
const selectedBsnm = sidoList?.data.sidoList.find((v) => v.sido === value)?.bsnmCd || '';
const selectedBsnm =
sidoList?.data.sidoList.find((v) => v.sido === value)?.bsnmCd || "";
setValues((prev) => ({
...prev,
sido: value,
gugun: '',
gugun: "",
bsnmCd: selectedBsnm,
}));
};
const handleSggChange = (value: string) => {
const listForBsnm = sggList?.data ?? [];
const selectedBsnm = value === 'ALL'
? (sidoList?.data.sidoList.find((v) => v.sido === values.sido)?.bsnmCd || '')
: (listForBsnm.find((v) => v.gugun === value)?.bsnmCd || '');
const selectedBsnm =
value === "ALL"
? sidoList?.data.sidoList.find((v) => v.sido === values.sido)
?.bsnmCd || ""
: listForBsnm.find((v) => v.gugun === value)?.bsnmCd || "";
setValues((prev) => ({
...prev,
@@ -145,66 +155,80 @@ const SearchForm = (props: SearchFormProps = {}) => {
<div className="card card-border">
<form onSubmit={handleSubmit}>
<div className="flex items-center gap-8 p-2">
{mergedProps.sido &&
{mergedProps.sido && (
<div className="flex items-center gap-2">
<Label className="text-label">
시도
</Label>
<Select value={values.sido} onValueChange={handleSidoChange}>
<Label className="text-label">시도</Label>
<Select
value={values.sido}
onValueChange={handleSidoChange}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="선택"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">선택</SelectItem>
{sidoList?.data?.sidoList?.map((sido) => (
<SelectItem key={sido.adstDstrCd} value={sido.sido}>
<SelectItem
key={sido.adstDstrCd}
value={sido.sido}
>
{sido.sido}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
{mergedProps.gugun &&
)}
{mergedProps.gugun && (
<div className="flex items-center gap-2">
<Label className="text-label">
시군구
</Label>
<Select value={values.gugun} onValueChange={handleSggChange}>
<Label className="text-label">시군구</Label>
<Select
value={values.gugun}
onValueChange={handleSggChange}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sggOptions?.map((sgg) => (
<SelectItem key={sgg.adstDstrCd} value={sgg.gugun}>
<SelectItem
key={sgg.adstDstrCd}
value={sgg.gugun}
>
{sgg.gugun}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
{mergedProps.year &&
)}
{mergedProps.year && (
<div className="flex items-center gap-2">
<Label className="text-label">
년도
</Label>
<Select value={values.year} onValueChange={(value) => setValues({ ...values, year: value })}>
<Label className="text-label">년도</Label>
<Select
value={values.year}
onValueChange={(value) =>
setValues({ ...values, year: value })
}
>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="선택"></SelectValue>
</SelectTrigger>
<SelectContent>
{sidoList?.data?.yearList.map((year) => (
<SelectItem key={year} value={String(year)}>
<SelectItem
key={year}
value={String(year)}
>
{year}년
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
{mergedProps.isDetail &&
)}
{mergedProps.isDetail && (
<div className="flex items-center gap-2">
<Label className="text-label">
세부 데이터 포함
@@ -212,17 +236,23 @@ const SearchForm = (props: SearchFormProps = {}) => {
<Checkbox
checked={values.isDetail}
onCheckedChange={(value) =>
setValues({ ...values, isDetail: value === true })
setValues({
...values,
isDetail: value === true,
})
}
/>
</div>
}
{mergedProps.order &&
)}
{mergedProps.order && (
<div className="flex items-center gap-2">
<Label className="text-label">
차수
</Label>
<Select value={values.order} onValueChange={(value) => setValues({ ...values, order: value })}>
<Label className="text-label">차수</Label>
<Select
value={values.order}
onValueChange={(value) =>
setValues({ ...values, order: value })
}
>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
@@ -231,9 +261,14 @@ const SearchForm = (props: SearchFormProps = {}) => {
</SelectContent>
</Select>
</div>
}
<div className="flex items-center gap-2 ml-auto">
<Button color="white" className="h-[3.2rem] w-[96px]" type="button" onClick={handleReset}>
)}
<div className="ml-auto flex items-center gap-2">
<Button
color="white"
className="h-[3.2rem] w-[96px]"
type="button"
onClick={handleReset}
>
초기화
</Button>
<Button className="h-[3.2rem] w-[96px]" type="submit">
@@ -241,35 +276,46 @@ const SearchForm = (props: SearchFormProps = {}) => {
</Button>
</div>
</div>
{(mergedProps.fctName || mergedProps.userMenual) &&
{(mergedProps.fctName || mergedProps.userMenual) && (
<div className="flex items-center p-2">
{mergedProps.fctName &&
{mergedProps.fctName && (
<div className="flex items-center gap-2">
<Label htmlFor="fctNmSearchInput" className="mr-2 text-label">
<Label
htmlFor="fctNmSearchInput"
className="mr-2 text-label"
>
시설명
</Label>
<Input
className="h-[3.2rem] w-[14rem] rounded-sm !text-[1.3rem] mr-4"
className="mr-4 h-[3.2rem] w-[14rem] rounded-sm !text-[1.3rem]"
id="fctNmSearchInput"
value={values.fctName}
onChange={(e) => setValues({ ...values, fctName: e.target.value })}
onChange={(e) =>
setValues({
...values,
fctName: e.target.value,
})
}
autoComplete="off"
/>
</div>
}
{mergedProps.userMenual &&
<div className="flex items-center gap-2 ml-auto">
<Button className="h-[3.2rem] w-[124px]" type="button">
)}
{mergedProps.userMenual && (
<div className="ml-auto flex items-center gap-2">
<Button
className="h-[3.2rem] w-[124px]"
type="button"
>
<i className="diveicon di-person-fill" />
사용자 메뉴얼
</Button>
</div>
}
)}
</div>
}
)}
</form>
</div>
);
};
export default SearchForm;
export default SearchForm;
@@ -13,14 +13,18 @@ import {
SelectContent,
SelectItem,
SelectValue,
LoadingWithAnimation
LoadingWithAnimation,
} from "@common/components";
import CompactDataGrid from "./CompactDataGrid";
import SaveButton from "../button/SaveButton";
import { useAlertStore } from "@/shared/lib";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import type { ApiResponse, SearchCondition, Sgg } from "@/entities/statistics/model/statistics-interface";
import type {
ApiResponse,
SearchCondition,
Sgg,
} from "@/entities/statistics/model/statistics-interface";
import type { ColumnInfo } from "../type/DataGrid.type";
import type {
SeweragePipeFacilitiesListRequest,
@@ -31,9 +35,24 @@ import { statisticsInputSewPipeApiService } from "@/entities/statistics/api/inpu
/** 다이얼로그 좌측: 처리시설 정보 (순번 없음) */
const treatmentFacilityColumnInfo: ColumnInfo = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "fctNm", fieldName: "fctNm", header: { text: "시설명" }, editable: false },
{
name: "ctpvNm",
fieldName: "ctpvNm",
header: { text: "시도" },
editable: false,
},
{
name: "sggNm",
fieldName: "sggNm",
header: { text: "시군구" },
editable: false,
},
{
name: "fctNm",
fieldName: "fctNm",
header: { text: "시설명" },
editable: false,
},
{
name: "fctCpc",
fieldName: "fctCpc",
@@ -49,9 +68,24 @@ const treatmentFacilityColumnInfo: ColumnInfo = {
/** 다이얼로그 우측: 행정구역 정보 (순번 없음, 헤더 체크박스는 showHeaderCheckbox) */
const adminRegionColumnInfo: ColumnInfo = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "emdNm", fieldName: "emdNm", header: { text: "읍면동" }, editable: false },
{
name: "ctpvNm",
fieldName: "ctpvNm",
header: { text: "시도" },
editable: false,
},
{
name: "sggNm",
fieldName: "sggNm",
header: { text: "시군구" },
editable: false,
},
{
name: "emdNm",
fieldName: "emdNm",
header: { text: "읍면동" },
editable: false,
},
],
columnLayout: ["ctpvNm", "sggNm", "emdNm"],
};
@@ -60,7 +94,9 @@ const getSidoListFetcher = async (): Promise<ApiResponse<SearchCondition>> => {
return statisticsAPi.getSidoList();
};
const getSggAllListFetcher = async (key: [string, string]): Promise<ApiResponse<Sgg[]>> => {
const getSggAllListFetcher = async (
key: [string, string],
): Promise<ApiResponse<Sgg[]>> => {
return statisticsAPi.getSggAllList(key[1]);
};
@@ -69,49 +105,72 @@ type SidoSggFacilityNameSearchFormProps = {
statsYr?: string;
};
const SidoSggFacilityNameSearchForm = ({ statsYr }: SidoSggFacilityNameSearchFormProps) => {
const SidoSggFacilityNameSearchForm = ({
statsYr,
}: SidoSggFacilityNameSearchFormProps) => {
const { setMessage: alert } = useAlertStore((state) => state);
const { data: sidoList } = useSWR<ApiResponse<SearchCondition>>("sido-list", getSidoListFetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(`시도 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`);
}
const { data: sidoList } = useSWR<ApiResponse<SearchCondition>>(
"sido-list",
getSidoListFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(
`시도 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`,
);
}
},
onError: () => {
alert(
`시도 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`,
);
},
},
onError: () => {
alert(`시도 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`);
},
});
);
const [sido, setSido] = useState<string>("ALL");
const [gugun, setGugun] = useState<string>("ALL");
const [fctNm, setFctNm] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [treatmentFacilityRows, setTreatmentFacilityRows] = useState<Record<string, string | number>[]>([]);
const [adminRegionRows, setAdminRegionRows] = useState<Record<string, string | number>[]>([]);
const [treatmentFacilityRows, setTreatmentFacilityRows] = useState<
Record<string, string | number>[]
>([]);
const [adminRegionRows, setAdminRegionRows] = useState<
Record<string, string | number>[]
>([]);
const sggKey: [string, string] | null = sido && sido !== "ALL" ? ["sgg-all", sido] : null;
const { data: sggList } = useSWR<ApiResponse<Sgg[]>>(sggKey, getSggAllListFetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(`시군구 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`);
}
const sggKey: [string, string] | null =
sido && sido !== "ALL" ? ["sgg-all", sido] : null;
const { data: sggList } = useSWR<ApiResponse<Sgg[]>>(
sggKey,
getSggAllListFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(
`시군구 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`,
);
}
},
onError: () => {
alert(
`시군구 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`,
);
},
},
onError: () => {
alert(`시군구 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`);
},
});
);
const sggOptions = sggList?.data ?? [];
/** 시설 목록·행정구역 API 공통 — 하수도 사업자코드(bsnmCd) */
@@ -129,7 +188,7 @@ const SidoSggFacilityNameSearchForm = ({ statsYr }: SidoSggFacilityNameSearchFor
setGugun("ALL");
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (!statsYr?.trim()) {
alert("상단에서 통계연도로 조회한 뒤 이용해 주세요.");
@@ -153,7 +212,10 @@ const SidoSggFacilityNameSearchForm = ({ statsYr }: SidoSggFacilityNameSearchFor
try {
setIsLoading(true);
const response = await statisticsInputSewPipeApiService.getFacilitiesList(params);
const response =
await statisticsInputSewPipeApiService.getFacilitiesList(
params,
);
if (response.status !== "success") {
alert(response.message ?? "처리시설 목록 조회에 실패했습니다.");
return;
@@ -178,7 +240,9 @@ const SidoSggFacilityNameSearchForm = ({ statsYr }: SidoSggFacilityNameSearchFor
}
};
const handleTreatmentFacilityRowClick = async (row: Record<string, string | number>) => {
const handleTreatmentFacilityRowClick = async (
row: Record<string, string | number>,
) => {
if (!statsYr?.trim()) {
alert("상단에서 통계연도로 조회한 뒤 이용해 주세요.");
return;
@@ -197,14 +261,17 @@ const SidoSggFacilityNameSearchForm = ({ statsYr }: SidoSggFacilityNameSearchFor
const params: SeweragePipeFacilityAdminAreaListRequest = {
statsYr: statsYr.trim(),
swtrmtFcltCd,
sewerBzmnCd: String(row.admdstCd ?? "").trim(),
sewerBzmnCd: String(row.admdstCd ?? "").trim(),
ctpvNm,
sggNm,
};
try {
setIsLoading(true);
const response = await statisticsInputSewPipeApiService.getFacilityAdminAreaList(params);
const response =
await statisticsInputSewPipeApiService.getFacilityAdminAreaList(
params,
);
if (response.status !== "success") {
alert(response.message ?? "행정구역 목록 조회에 실패했습니다.");
return;
@@ -234,7 +301,7 @@ const SidoSggFacilityNameSearchForm = ({ statsYr }: SidoSggFacilityNameSearchFor
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-4">
<div className="flex items-center gap-2">
<Label className="text-label shrink-0">시도</Label>
<Label className="shrink-0 text-label">시도</Label>
<Select value={sido} onValueChange={handleSidoChange}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체" />
@@ -242,7 +309,10 @@ const SidoSggFacilityNameSearchForm = ({ statsYr }: SidoSggFacilityNameSearchFor
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sidoList?.data?.sidoList?.map((item) => (
<SelectItem key={item.adstDstrCd} value={item.sido}>
<SelectItem
key={item.adstDstrCd}
value={item.sido}
>
{item.sido}
</SelectItem>
))}
@@ -250,31 +320,42 @@ const SidoSggFacilityNameSearchForm = ({ statsYr }: SidoSggFacilityNameSearchFor
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="text-label shrink-0">시군구</Label>
<Select value={gugun} onValueChange={setGugun} disabled={!sido || sido === "ALL"}>
<Label className="shrink-0 text-label">시군구</Label>
<Select
value={gugun}
onValueChange={setGugun}
disabled={!sido || sido === "ALL"}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sggOptions?.map((sgg) => (
<SelectItem key={sgg.adstDstrCd} value={sgg.gugun}>
<SelectItem
key={sgg.adstDstrCd}
value={sgg.gugun}
>
{sgg.gugun}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 min-w-[20rem] flex-1">
<Label className="text-label shrink-0">시설명</Label>
<div className="flex min-w-[20rem] flex-1 items-center gap-2">
<Label className="shrink-0 text-label">시설명</Label>
<Input
className="flex-1 min-w-[12rem]"
className="min-w-[12rem] flex-1"
value={fctNm}
onChange={(e) => setFctNm(e.target.value)}
placeholder="시설명 입력"
/>
</div>
<Button type="submit" className="h-[3.2rem] w-[96px]" color="primary">
<Button
type="submit"
className="h-[3.2rem] w-[96px]"
color="primary"
>
조회
</Button>
</div>
@@ -282,14 +363,18 @@ const SidoSggFacilityNameSearchForm = ({ statsYr }: SidoSggFacilityNameSearchFor
<SaveButton saveFunc={handleSaveAdminRegions} />
</div>
</form>
{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" />
{isLoading && (
<div className="[data-theme='black']:bg-transparent absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
</div>}
<div className="flex gap-[1.2rem] items-stretch w-full min-w-0">
<div className="flex-1 flex flex-col gap-2 min-w-0">
<h3 className="text-[1.4rem] font-medium text-foreground">처리시설 정보</h3>
)}
<div className="flex w-full min-w-0 items-stretch gap-[1.2rem]">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<h3 className="text-[1.4rem] font-medium text-foreground">
처리시설 정보
</h3>
<CompactDataGrid
columnInfo={treatmentFacilityColumnInfo}
rowData={treatmentFacilityRows}
@@ -300,9 +385,11 @@ const SidoSggFacilityNameSearchForm = ({ statsYr }: SidoSggFacilityNameSearchFor
// compactGridHeightClass="h-[min(40vh,28rem)] min-h-[16rem]"
/>
</div>
<div className="flex-1 flex flex-col gap-2 min-w-0">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-[1.4rem] font-medium text-foreground">행정구역 정보</h3>
<h3 className="text-[1.4rem] font-medium text-foreground">
행정구역 정보
</h3>
</div>
<CompactDataGrid
columnInfo={adminRegionColumnInfo}
@@ -1,60 +1,63 @@
"use client";
import { useCallback, useState } from 'react';
import { useCallback, useState } from "react";
import {
Button,
Label,
LoadingWithAnimation,
}
from "@common/components";
import DataGrid from '@/components/module/DataGrid';
import { CodeSelectWithAll } from '@/entities/code';
import { Button, Label, LoadingWithAnimation } from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import { CodeSelectWithAll } from "@/entities/code";
import { statisticsAPi } from '@/entities/statistics/api/statistics-service';
import type { StatisticsLogRequest } from '@/entities/statistics/model/statistics-interface';
import { toGridRows } from '@/components/util/grid/gridFunction';
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import type { StatisticsLogRequest } from "@/entities/statistics/model/statistics-interface";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { LogHistoryColumn } from '@/components/util/column/management/LogHistoryColumn';
import { LogHistoryTablePagination } from '@/components/statistics/management/LogHistoryTablePagination';
import { LogHistoryColumn } from "@/components/util/column/management/LogHistoryColumn";
import { LogHistoryTablePagination } from "@/components/statistics/management/LogHistoryTablePagination";
const PAGE_SIZE = 100;
const LogHistory = () => {
// const { setMessage: alert } = useAlertStore((state) => state);
const [statsCd, setStatsCd] = useState<string>('');
const [statsCd, setStatsCd] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [rowData, setRowData] = useState<Record<string, string | number>[]>(
[],
);
const [pageIndex, setPageIndex] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const loadLogs = useCallback(async (page1Based: number) => {
const param: StatisticsLogRequest = {
statsCd: (statsCd === '' || statsCd === 'ALL') ? '' : statsCd,
page: page1Based,
size: PAGE_SIZE,
};
const loadLogs = useCallback(
async (page1Based: number) => {
const param: StatisticsLogRequest = {
statsCd: statsCd === "" || statsCd === "ALL" ? "" : statsCd,
page: page1Based,
size: PAGE_SIZE,
};
try {
setIsLoading(true);
try {
setIsLoading(true);
const response = await statisticsAPi.getLogList(param);
const response = await statisticsAPi.getLogList(param);
if (response.status === 'success') {
setRowData(toGridRows(response.data?.statsLogList ?? []));
setTotalPages(response.data?.pageInfo?.totalPages ?? 0);
setPageIndex(Math.max(0, page1Based - 1));
} else {
alert(response.message || '데이터 조회 중 오류가 발생했습니다.');
if (response.status === "success") {
setRowData(toGridRows(response.data?.statsLogList ?? []));
setTotalPages(response.data?.pageInfo?.totalPages ?? 0);
setPageIndex(Math.max(0, page1Based - 1));
} else {
alert(
response.message ||
"데이터 조회 중 오류가 발생했습니다.",
);
}
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, [statsCd]);
},
[statsCd],
);
const handleSearch = async (e: React.FormEvent<HTMLFormElement>) => {
const handleSearch = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
await loadLogs(1);
};
@@ -65,9 +68,7 @@ const LogHistory = () => {
<form onSubmit={handleSearch}>
<div className="flex items-center gap-8 p-2">
<div className="flex items-center gap-2">
<Label className="text-label">
통계항목
</Label>
<Label className="text-label">통계항목</Label>
<CodeSelectWithAll
upCd="STITEM"
value={statsCd}
@@ -76,27 +77,36 @@ const LogHistory = () => {
className="w-[16rem]"
/>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button color="green" className="h-[3.2rem] w-[96px]" type="button">
<div className="ml-auto flex items-center gap-2">
<Button
color="green"
className="h-[3.2rem] w-[96px]"
type="button"
>
초기화
</Button>
<Button className="h-[3.2rem] w-[96px]" type="submit">
<Button
className="h-[3.2rem] w-[96px]"
type="submit"
>
조회
</Button>
</div>
</div>
</form>
</div>
{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" />
{isLoading && (
<div className="[data-theme='black']:bg-transparent absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
</div>}
)}
<DataGrid
columnInfo={LogHistoryColumn}
isExcelImport={false}
rowData={rowData}
setRowData={() => { }}
setRowData={() => {}}
/>
<LogHistoryTablePagination
pageCount={totalPages}
@@ -109,4 +119,4 @@ const LogHistory = () => {
);
};
export default LogHistory;
export default LogHistory;
@@ -211,7 +211,7 @@ function VerificationArticleManagement() {
setDialogOpen(true);
}
function handleSubmit(e: React.FormEvent) {
function handleSubmit(e: React.SubmitEvent) {
e.preventDefault();
handleSearch();
}
+121 -113
View File
@@ -6,124 +6,132 @@ import { LoginApi } from "@/entities/auth";
import "./login.css";
function LoginPage() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const userId = formData.get("id") as string;
const password = formData.get("password") as string;
const formData = new FormData(e.target as HTMLFormElement);
const userId = formData.get("id") as string;
const password = formData.get("password") as string;
await LoginApi.login({ userId, password });
window.location.href = "/statistics";
};
await LoginApi.login({ userId, password });
window.location.href = "/statistics";
};
return (
<div className="login fx">
<div className="left_box fx">
<div className="text">
<h1>
유역하수도통합플랫폼
<br />
<span>하수도통계 관리시스템</span>
</h1>
<p>유역하수도통합플랫폼 하수도통계 관리시스템에 오신 것을 환영합니다.</p>
</div>
<div className="img">
<Image src={images.logo} width={229} height={46} alt="logo" />
</div>
</div>
<form
id="loginForm"
name="loginForm"
method="post"
onSubmit={handleSubmit}
>
<div className="login_box fx">
<ul className="fx">
<li>
<h2>ID</h2>
<div className="input_st st1">
<i className="diveicon di-account-box" />
<input
type="text"
className="id"
title="아이디를 입력하세요."
id="id"
name="id"
maxLength={12}
/>
</div>
</li>
<li>
<h2>PASSWORD</h2>
<div className="input_st st2">
<i className="diveicon di-key" />
<input
type="password"
className="input_style"
maxLength={25}
title="비밀번호를 입력하세요."
id="password"
name="password"
/>
</div>
</li>
</ul>
<div className="bottom_box fx">
<div className="checks fx">
<input
type="checkbox"
name="checkId"
title="로그인ID 저장여부"
id="checkId"
/>
<label htmlFor="checkId">아이디저장</label>
return (
<div className="login fx">
<div className="left_box fx">
<div className="text">
<h1>
유역하수도통합플랫폼
<br />
<span>하수도통계 관리시스템</span>
</h1>
<p>
유역하수도통합플랫폼 하수도통계 관리시스템에 오신 것을
환영합니다.
</p>
</div>
<div className="img">
<Image
src={images.logo}
width={229}
height={46}
alt="logo"
/>
</div>
</div>
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
</div>
<button type="submit" className="login_btn fx">
<span>LOGIN</span>
</button>
<div className="footer">
<div className="copy fx">
<p>copyright © 2023 KECO. All rights reserved.</p>
</div>
<div className="fot_btn fx">
<a
href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do"
target="_blank"
>
개인정보처리방침
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do"
target="_blank"
>
저작권정책안내
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do"
target="_blank"
>
이메일무단수집거부
</a>
</div>
<p className="addr fx">
<span>
주소 : (22689) 인천광역시 서구 환경로 42(경서동
종합환경연구단지)
</span>
<span>
전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723,
3722
</span>
<span>팩스 :032-590-3729</span>
</p>
</div>
<form
id="loginForm"
name="loginForm"
method="post"
onSubmit={handleSubmit}
>
<div className="login_box fx">
<ul className="fx">
<li>
<h2>ID</h2>
<div className="input_st st1">
<i className="diveicon di-account-box" />
<input
type="text"
className="id"
title="아이디를 입력하세요."
id="id"
name="id"
maxLength={12}
/>
</div>
</li>
<li>
<h2>PASSWORD</h2>
<div className="input_st st2">
<i className="diveicon di-key" />
<input
type="password"
className="input_style"
maxLength={25}
title="비밀번호를 입력하세요."
id="password"
name="password"
/>
</div>
</li>
</ul>
<div className="bottom_box fx">
<div className="checks fx">
<input
type="checkbox"
name="checkId"
title="로그인ID 저장여부"
id="checkId"
/>
<label htmlFor="checkId">아이디저장</label>
</div>
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
</div>
<button type="submit" className="login_btn fx">
<span>LOGIN</span>
</button>
<div className="footer">
<div className="copy fx">
<p>copyright © 2023 KECO. All rights reserved.</p>
</div>
<div className="fot_btn fx">
<a
href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do"
target="_blank"
>
개인정보처리방침
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do"
target="_blank"
>
저작권정책안내
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do"
target="_blank"
>
이메일무단수집거부
</a>
</div>
<p className="addr fx">
<span>
주소 : (22689) 인천광역시 서구 환경로 42(경서동
종합환경연구단지)
</span>
<span>
전화 : (시스템) 032-590-4321, 3752, 4314 / (통
계) 032-590-3723, 3722
</span>
<span>팩스 :032-590-3729</span>
</p>
</div>
</div>
</form>
</div>
</form>
</div>
);
);
}
export { LoginPage };
+64 -49
View File
@@ -2,18 +2,13 @@ import { numSize } from "@/app/store/common/code/basicCode";
import type { TableUpperProps } from "@/app/store/common/type/header";
import type { rainChkZodType } from "@/app/store/prfmncReg/chkgdnReg/rainChkgdn";
import type { rswtChkZodType } from "@/app/store/prfmncReg/chkgdnReg/rswrtChkgdn";
import {
Checkbox,
Input,
TableCell,
TableRow,
} from "@common/components/ui";
import { Checkbox, Input, TableCell, TableRow } from "@common/components/ui";
import moment from "moment";
import {
codeToText,
formatCommaNumber,
formatYyyymmdd,
handleCommaNumberChange
handleCommaNumberChange,
} from "./common";
export const rainChkTableData = (chkData: rainChkZodType[]) => {
@@ -73,10 +68,10 @@ export const rswtChkTableData = (chkData: rswtChkZodType[]) => {
root.rswtrDetails.forEach((detail) => {
let flatRswtChk = {};
const findGuide = root.guideDetails.find(
(guide) => guide.utztnUsgCd === detail.utztnUsgCd
)
(guide) => guide.utztnUsgCd === detail.utztnUsgCd,
);
const findPoluttion = root.rswtrPollutions.find(
(polution) => polution.utztnUsgCd === detail.utztnUsgCd
(polution) => polution.utztnUsgCd === detail.utztnUsgCd,
);
flatRswtChk = {
...root,
@@ -84,7 +79,7 @@ export const rswtChkTableData = (chkData: rswtChkZodType[]) => {
...findGuide,
...findPoluttion,
...flatRswtChk,
utztnUsgCdText: codeToText(detail.utztnUsgCd, 'dmdUszCd'),
utztnUsgCdText: codeToText(detail.utztnUsgCd, "dmdUszCd"),
};
makeRswtData.push(flatRswtChk);
});
@@ -101,12 +96,12 @@ export const chRowspanTable = (
handleChange: (
rowIndex: number,
field: keyof (typeof makeTableData)[0],
value: string | number
value: string | number,
) => void,
toggleSelection: (pk: number, checked: boolean) => void,
selectedIds: number[],
handleCellClick: (index: number, headId: string) => void,
disable?: boolean
disable?: boolean,
) => {
let order = 0;
// rowspan된 셀 렌더링 여부 추적용
@@ -140,7 +135,11 @@ export const chRowspanTable = (
key={key}
rowSpan={rowspan}
className="align-middle text-center whitespace-normal break-words"
style={{ width: head.widthPercent ? `${head.widthPercent}%` : undefined }}
style={{
width: head.widthPercent
? `${head.widthPercent}%`
: undefined,
}}
>
{key === "checkBox" ? (
<Checkbox
@@ -153,16 +152,16 @@ export const chRowspanTable = (
) : key === "order" ? (
++order
) : // value
["anlOperCst", "dtfcltCpct", "prcsCpct"].includes( //연간운영비, 저류조용량, 처리용량
head.id
["anlOperCst", "dtfcltCpct", "prcsCpct"].includes(
//연간운영비, 저류조용량, 처리용량
head.id,
) ? (
formatCommaNumber(value)
) : key === "chckYmd" ? (
<>{formatYyyymmdd(value)}</>
) :
(
<>{value}</>
)}
formatCommaNumber(value)
) : key === "chckYmd" ? (
<>{formatYyyymmdd(value)}</>
) : (
<>{value}</>
)}
</TableCell>
);
}
@@ -173,7 +172,11 @@ export const chRowspanTable = (
key={key}
className="text-center whitespace-normal break-words"
onClick={() => handleCellClick(rowIndex, head.id)}
style={{ width: head.widthPercent ? `${head.widthPercent}%` : undefined }}
style={{
width: head.widthPercent
? `${head.widthPercent}%`
: undefined,
}}
>
{head.edit === true ? (
[
@@ -187,15 +190,18 @@ export const chRowspanTable = (
type={head.type}
onChange={(e) => {
handleCommaNumberChange(e, (val) =>
handleChange(rowIndex, head.id, val)
handleChange(rowIndex, head.id, val),
);
}}
value={formatCommaNumber(value) || ""}
disabled={!selectedIds.includes(row.chkgdnSn)}
maxLength={numSize}
onInput={(e: React.FormEvent<HTMLInputElement>) => {
onInput={(e: React.SubmitEvent<HTMLInputElement>) => {
const el = e.currentTarget;
if (head.type === "number" && el.value.length > numSize) {
if (
head.type === "number" &&
el.value.length > numSize
) {
el.value = el.value.slice(0, numSize);
}
}}
@@ -226,9 +232,12 @@ export const chRowspanTable = (
value={value || ""}
disabled={!selectedIds.includes(row.chkgdnSn)}
maxLength={numSize}
onInput={(e: React.FormEvent<HTMLInputElement>) => {
onInput={(e: React.SubmitEvent<HTMLInputElement>) => {
const el = e.currentTarget;
if (head.type === "number" && el.value.length > numSize) {
if (
head.type === "number" &&
el.value.length > numSize
) {
el.value = el.value.slice(0, numSize);
}
}}
@@ -237,12 +246,12 @@ export const chRowspanTable = (
) : ["smll"].includes(head.id) && head.type === "text" ? (
<>-</>
) : [
"totalSplyUswtrUtztnQnt",
"totalRuseFcltUtztnQnt",
"splyUswtrUtztnQnt",
"ruseFcltUtztnQnt",
"levyAmt",
].includes(head.id) ? (
"totalSplyUswtrUtztnQnt",
"totalRuseFcltUtztnQnt",
"splyUswtrUtztnQnt",
"ruseFcltUtztnQnt",
"levyAmt",
].includes(head.id) ? (
<span
className={
[
@@ -281,7 +290,7 @@ export const chRowspanTable = (
export const headVisibleChange = (
headList: TableUpperProps[],
isEdit: boolean
isEdit: boolean,
) => {
return headList.forEach((head) => {
if (head.edit !== undefined) head.edit = isEdit;
@@ -295,7 +304,7 @@ const handleChange = (
rowIndex: number,
field: any,
value: string,
setInputData: (data: any) => void
setInputData: (data: any) => void,
) => {
if (Number(value) < 0 || !Number(value)) {
value = "0";
@@ -309,17 +318,16 @@ const handleChange = (
};
}
return row;
})
}),
);
};
// 재이용실적등록 > 지도점검결과 등록 - 상세 modal의 body
export const InputTableBody = (
chgRes: any[] | [],
headList: TableUpperProps[],
setInputData: (data: any[]) => void,
isEditable: boolean = true
isEditable: boolean = true,
) => {
// mergeCell 처리용
const getRowSpan = (data: any[], key: string, rowIndex: number) => {
@@ -360,13 +368,17 @@ export const InputTableBody = (
// </TableCell>
// );
// }
const isTextColumn = head.id === "구분" || head.id === "utztnUsgCdText";
const isTextColumn =
head.id === "구분" || head.id === "utztnUsgCdText";
const textAlignClass = isTextColumn
? "text-center"
: "text-right";
// 총합계 행이면 "구분"에서 colSpan=2로 병합
if ( (data["구분"] === 0 || data["용수명"] === 0) && head.id === "구분" ) {
if (
(data["구분"] === 0 || data["용수명"] === 0) &&
head.id === "구분"
) {
return (
<TableCell
key={head.id + headIndex}
@@ -378,7 +390,10 @@ export const InputTableBody = (
);
}
// 총합계(합계) 행이면 "용수명" 컬럼은 렌더링하지 않음
if ( (data["구분"] === 0 || data["용수명"] === 0) && head.id === "용수명" ) {
if (
(data["구분"] === 0 || data["용수명"] === 0) &&
head.id === "용수명"
) {
return null;
}
@@ -427,8 +442,8 @@ export const InputTableBody = (
dataIndex,
head.id,
String(val),
setInputData
)
setInputData,
),
)
}
value={formatCommaNumber(data[head.id as keyof any]) ?? ""}
@@ -470,7 +485,7 @@ export const pbnstTableBody = (
chgRes: any[] | [],
headList: TableUpperProps[],
setInputData: (data: any[]) => void,
isEditable: boolean = true
isEditable: boolean = true,
) => {
// mergeCell 처리용
const getRowSpan = (data: any[], key: string, rowIndex: number) => {
@@ -584,8 +599,8 @@ export const pbnstTableBody = (
dataIndex,
head.id,
String(val),
setInputData
)
setInputData,
),
)
}
value={formatCommaNumber(data[head.id as keyof any]) ?? ""}
@@ -626,4 +641,4 @@ export const pbnstTableBody = (
)}
</>
);
};
};
@@ -14,7 +14,9 @@ interface SignupModalProps {
}
function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
const [currentStep, setCurrentStep] = useState<'agreement' | 'signup'>('agreement');
const [currentStep, setCurrentStep] = useState<"agreement" | "signup">(
"agreement",
);
const [agreed, setAgreed] = useState(false);
const [formData, setFormData] = useState({
userId: "",
@@ -47,7 +49,7 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
// 모달이 닫힐 때 초기화
useEffect(() => {
if (!open) {
setCurrentStep('agreement');
setCurrentStep("agreement");
setAgreed(false);
setFormData({
userId: "",
@@ -81,17 +83,19 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
const handleNextStep = () => {
if (agreed) {
setCurrentStep('signup');
setCurrentStep("signup");
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
setFormData((prev) => ({ ...prev, [name]: value }));
// 에러 초기화
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: "" }));
setErrors((prev) => ({ ...prev, [name]: "" }));
}
};
@@ -102,20 +106,27 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
if (!formData.userId) newErrors.userId = "아이디를 입력해주세요.";
if (!formData.userName) newErrors.userName = "이름을 입력해주세요.";
if (!formData.password) newErrors.password = "비밀번호를 입력해주세요.";
if (!formData.confirmPassword) newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
if (!formData.confirmPassword)
newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 입력해주세요.";
if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요.";
if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요.";
// 비밀번호 확인
if (formData.password && formData.confirmPassword && formData.password !== formData.confirmPassword) {
if (
formData.password &&
formData.confirmPassword &&
formData.password !== formData.confirmPassword
) {
newErrors.confirmPassword = "비밀번호가 일치하지 않습니다.";
}
// 비밀번호 정규식 검증
const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/;
const passwordRegex =
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/;
if (formData.password && !passwordRegex.test(formData.password)) {
newErrors.password = "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.";
newErrors.password =
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.";
}
// 이메일 형식 검증
@@ -124,8 +135,10 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
}
// 중복 체크 검증
if (idAvailable === false) newErrors.userId = "이미 사용 중인 아이디입니다.";
if (emailAvailable === false) newErrors.email = "이미 사용 중인 이메일입니다.";
if (idAvailable === false)
newErrors.userId = "이미 사용 중인 아이디입니다.";
if (emailAvailable === false)
newErrors.email = "이미 사용 중인 이메일입니다.";
setErrors(newErrors);
const isValid = Object.keys(newErrors).length === 0;
@@ -135,7 +148,7 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
const checkUserIdDuplicate = async () => {
if (!formData.userId) {
setErrors(prev => ({ ...prev, userId: "아이디를 입력해주세요." }));
setErrors((prev) => ({ ...prev, userId: "아이디를 입력해주세요." }));
return;
}
@@ -147,19 +160,25 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
{ userId: formData.userId }
{ userId: formData.userId },
);
const exists = response.data;
setIdAvailable(!exists);
if (exists) {
setErrors(prev => ({ ...prev, userId: "이미 사용 중인 아이디입니다." }));
setErrors((prev) => ({
...prev,
userId: "이미 사용 중인 아이디입니다.",
}));
} else {
setErrors(prev => ({ ...prev, userId: "" }));
setErrors((prev) => ({ ...prev, userId: "" }));
}
} catch (error) {
console.error("아이디 중복 체크 오류:", error);
setErrors(prev => ({ ...prev, userId: "아이디 중복 체크 중 오류가 발생했습니다." }));
setErrors((prev) => ({
...prev,
userId: "아이디 중복 체크 중 오류가 발생했습니다.",
}));
} finally {
setIsCheckingId(false);
}
@@ -167,7 +186,7 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
const checkEmailDuplicate = async () => {
if (!formData.email) {
setErrors(prev => ({ ...prev, email: "이메일을 입력해주세요." }));
setErrors((prev) => ({ ...prev, email: "이메일을 입력해주세요." }));
return;
}
@@ -179,28 +198,34 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
{ email: formData.email }
{ email: formData.email },
);
const exists = response.data;
setEmailAvailable(!exists);
if (exists) {
setErrors(prev => ({ ...prev, email: "이미 사용 중인 이메일입니다." }));
setErrors((prev) => ({
...prev,
email: "이미 사용 중인 이메일입니다.",
}));
} else {
setErrors(prev => ({ ...prev, email: "" }));
setErrors((prev) => ({ ...prev, email: "" }));
}
} catch (error) {
console.error("이메일 중복 체크 오류:", error);
setErrors(prev => ({ ...prev, email: "이메일 중복 체크 중 오류가 발생했습니다." }));
setErrors((prev) => ({
...prev,
email: "이메일 중복 체크 중 오류가 발생했습니다.",
}));
} finally {
setIsCheckingEmail(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.SubmitEvent) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨");
if (!validateForm()) {
console.log("폼 검증 실패");
return;
@@ -230,19 +255,19 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
// SignupClient를 통해 익명사용자 회원가입 API 호출
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL
baseURL: BASE_URL,
});
const response = await signupClient.post(
`${ENDPOINT.USER_SERVICE.USERS}/anonymouse/users/join`,
signupData
signupData,
);
console.log("회원가입 성공:", response.data);
// 성공 시 모달 닫기 및 콜백 호출
onSuccess();
onClose();
// 폼 초기화
setFormData({
userId: "",
@@ -267,13 +292,12 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
} catch (error: unknown) {
console.error("회원가입 오류:", error);
if (error instanceof Error) {
setErrors(prev => ({ ...prev, submit: error.message }));
setErrors((prev) => ({ ...prev, submit: error.message }));
} else {
setErrors(prev => ({ ...prev, submit: "회원가입에 실패했습니다." }));
setErrors((prev) => ({ ...prev, submit: "회원가입에 실패했습니다." }));
}
} finally {
setIsLoading(false);
@@ -286,36 +310,52 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{currentStep === 'agreement' ? '개인정보제공동의' : '회원가입'}</h2>
<button className="close-button" onClick={onClose}>×</button>
<h2>
{currentStep === "agreement" ? "개인정보제공동의" : "회원가입"}
</h2>
<button className="close-button" onClick={onClose}>
×
</button>
</div>
{currentStep === 'agreement' ? (
{currentStep === "agreement" ? (
<div className="agreement-content">
<div className="agreement-text">
<h3>개인정보 수집 및 이용 동의</h3>
<p>회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.</p>
<h4>1. 수집하는 개인정보 항목</h4>
<ul>
<li>필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, 기관구분</li>
<li>선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, 상세기관코드, 사업자등록번호</li>
<li>
필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분,
기관구분
</li>
<li>
선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드,
상세기관코드, 사업자등록번호
</li>
</ul>
<h4>2. 개인정보의 수집 및 이용목적</h4>
<ul>
<li>회원가입 및 관리</li>
<li>서비스 제공 및 운영</li>
<li>고객상담 및 문의응답</li>
</ul>
<h4>3. 개인정보의 보유 및 이용기간</h4>
<p>회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)</p>
<p>
회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당
기간까지)
</p>
<h4>4. 동의 거부권 및 거부 시 불이익</h4>
<p>개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 시 회원가입이 제한됩니다.</p>
<p>
개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부
시 회원가입이 제한됩니다.
</p>
</div>
<div className="agreement-checkbox">
<label>
<input
@@ -326,13 +366,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
<span>개인정보 수집 및 이용에 동의합니다.</span>
</label>
</div>
<div className="agreement-actions">
<button
type="button"
onClick={onClose}
className="cancel-button"
>
<button type="button" onClick={onClose} className="cancel-button">
취소
</button>
<button
@@ -349,7 +385,7 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
<form onSubmit={handleSubmit} className="signup-form">
<div className="form-section">
<h3>기본 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="userId">아이디 *</label>
@@ -373,8 +409,14 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
{isCheckingId ? "확인중..." : "중복확인"}
</button>
</div>
{idAvailable === true && <span className="success-message">사용 가능한 아이디입니다.</span>}
{errors.userId && <span className="error-message">{errors.userId}</span>}
{idAvailable === true && (
<span className="success-message">
사용 가능한 아이디입니다.
</span>
)}
{errors.userId && (
<span className="error-message">{errors.userId}</span>
)}
</div>
</div>
@@ -390,7 +432,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
placeholder="이름을 입력하세요"
disabled={isLoading}
/>
{errors.userName && <span className="error-message">{errors.userName}</span>}
{errors.userName && (
<span className="error-message">{errors.userName}</span>
)}
</div>
</div>
@@ -416,8 +460,14 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
{isCheckingEmail ? "확인중..." : "중복확인"}
</button>
</div>
{emailAvailable === true && <span className="success-message">사용 가능한 이메일입니다.</span>}
{errors.email && <span className="error-message">{errors.email}</span>}
{emailAvailable === true && (
<span className="success-message">
사용 가능한 이메일입니다.
</span>
)}
{errors.email && (
<span className="error-message">{errors.email}</span>
)}
</div>
</div>
@@ -434,7 +484,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={20}
disabled={isLoading}
/>
{errors.password && <span className="error-message">{errors.password}</span>}
{errors.password && (
<span className="error-message">{errors.password}</span>
)}
</div>
</div>
@@ -451,7 +503,11 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={20}
disabled={isLoading}
/>
{errors.confirmPassword && <span className="error-message">{errors.confirmPassword}</span>}
{errors.confirmPassword && (
<span className="error-message">
{errors.confirmPassword}
</span>
)}
</div>
</div>
@@ -468,7 +524,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={10}
disabled={isLoading}
/>
{errors.pswdHnt && <span className="error-message">{errors.pswdHnt}</span>}
{errors.pswdHnt && (
<span className="error-message">{errors.pswdHnt}</span>
)}
</div>
</div>
@@ -485,14 +543,16 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={100}
disabled={isLoading}
/>
{errors.pswdAns && <span className="error-message">{errors.pswdAns}</span>}
{errors.pswdAns && (
<span className="error-message">{errors.pswdAns}</span>
)}
</div>
</div>
</div>
<div className="form-section">
<h3>연락처 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="mphn">휴대폰 번호</label>
@@ -506,7 +566,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={11}
disabled={isLoading}
/>
{errors.mphn && <span className="error-message">{errors.mphn}</span>}
{errors.mphn && (
<span className="error-message">{errors.mphn}</span>
)}
</div>
</div>
@@ -523,14 +585,16 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={11}
disabled={isLoading}
/>
{errors.telno && <span className="error-message">{errors.telno}</span>}
{errors.telno && (
<span className="error-message">{errors.telno}</span>
)}
</div>
</div>
</div>
<div className="form-section">
<h3>주소 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="zip">우편번호</label>
@@ -544,7 +608,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={5}
disabled={isLoading}
/>
{errors.zip && <span className="error-message">{errors.zip}</span>}
{errors.zip && (
<span className="error-message">{errors.zip}</span>
)}
</div>
</div>
@@ -561,7 +627,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={200}
disabled={isLoading}
/>
{errors.addr && <span className="error-message">{errors.addr}</span>}
{errors.addr && (
<span className="error-message">{errors.addr}</span>
)}
</div>
</div>
@@ -578,14 +646,16 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={200}
disabled={isLoading}
/>
{errors.daddr && <span className="error-message">{errors.daddr}</span>}
{errors.daddr && (
<span className="error-message">{errors.daddr}</span>
)}
</div>
</div>
</div>
<div className="form-section">
<h3>기관 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="laeSeCd">법인구분 *</label>
@@ -601,7 +671,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
<option value="02">민간기업</option>
<option value="03">개인</option>
</select>
{errors.laeSeCd && <span className="error-message">{errors.laeSeCd}</span>}
{errors.laeSeCd && (
<span className="error-message">{errors.laeSeCd}</span>
)}
</div>
</div>
@@ -621,7 +693,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
<option value="03">공공기관</option>
<option value="04">민간기업</option>
</select>
{errors.ogdpSeCd && <span className="error-message">{errors.ogdpSeCd}</span>}
{errors.ogdpSeCd && (
<span className="error-message">{errors.ogdpSeCd}</span>
)}
</div>
</div>
@@ -638,7 +712,9 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={10}
disabled={isLoading}
/>
{errors.instSeCd && <span className="error-message">{errors.instSeCd}</span>}
{errors.instSeCd && (
<span className="error-message">{errors.instSeCd}</span>
)}
</div>
</div>
@@ -655,21 +731,25 @@ function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
maxLength={10}
disabled={isLoading}
/>
{errors.dtlInstCd && <span className="error-message">{errors.dtlInstCd}</span>}
{errors.dtlInstCd && (
<span className="error-message">{errors.dtlInstCd}</span>
)}
</div>
</div>
</div>
{errors.submit && (
<div className="form-row">
<div className="error-message submit-error">{errors.submit}</div>
<div className="error-message submit-error">
{errors.submit}
</div>
</div>
)}
<div className="form-actions">
<button
type="button"
onClick={() => setCurrentStep('agreement')}
onClick={() => setCurrentStep("agreement")}
className="back-button"
disabled={isLoading}
>
@@ -305,7 +305,7 @@ function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) {
}
};
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.SubmitEvent) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨 - handleSubmit");
console.log("현재 폼 데이터:", formData);
@@ -7,8 +7,10 @@ import "./login.css";
function LoginPage() {
const hostname = window.location.hostname;
if (hostname.includes("hasuon.or.kr")) window.location.href = process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL||'/auth/login';
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
if (hostname.includes("hasuon.or.kr"))
window.location.href =
process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
@@ -129,4 +131,3 @@ function LoginPage() {
}
export { LoginPage };
@@ -12,7 +12,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "../popover";
import "./input-date-picker.css";
export interface InputDatePickerProps {
value?: Date;
value?: Date | null;
defaultValue?: Date;
onChange?: (date: Date | undefined) => void;
placeholder?: string;
@@ -34,7 +34,7 @@ export function InputDatePicker({
const [internalDate, setInternalDate] = useState<Date | undefined>(
defaultValue,
);
const [month, setMonth] = useState<Date | undefined>(
const [month, setMonth] = useState<Date | null | undefined>(
isControlled ? controlledValue : defaultValue,
);
@@ -112,9 +112,9 @@ export function InputDatePicker({
>
<Calendar
mode="single"
selected={date}
selected={date ?? undefined}
captionLayout="dropdown"
month={month}
month={month ?? undefined}
onMonthChange={setMonth}
onSelect={(newDate) => {
handleDateChange(newDate);