This commit is contained in:
2026-04-13 16:49:42 +09:00
565 changed files with 14078 additions and 14798 deletions
+3 -1
View File
@@ -24,7 +24,9 @@ jobs:
run: |
ssh -i ~/.ssh/deploy_key -p 28 onasys@192.168.0.4 << 'EOF'
cd ~/frontend
git pull origin main
git restore .
git clean -fd
git pull origin deploy
pnpm install
pnpm turbo build
pm2 reload all
+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,
@@ -6,6 +6,7 @@ import { BaseLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
import { tokenManager } from "@/entities/auth";
export default function WithBaseLayout({
children,
@@ -26,6 +27,7 @@ export default function WithBaseLayout({
return (
<BaseLayout
queryClient={queryClient}
tokenManager={tokenManager}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
@@ -0,0 +1,3 @@
import { NtftalkHistoryPage } from "@/pages/ntftalk-history";
export default NtftalkHistoryPage;
@@ -0,0 +1,3 @@
import { NtftalkTempPage } from "@/pages/ntftalk-temp";
export default NtftalkTempPage;
+14 -10
View File
@@ -2,6 +2,7 @@ import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
COOKIE_DOMAIN,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
@@ -60,18 +61,11 @@ async function handler(
[AUTH_USER_ID]: userId,
});
const forwardedHost = request.headers.get("x-forwarded-host");
const host = request.headers.get("host");
const actualHost = forwardedHost || host || new URL(request.url).hostname.toString();
// 운영 환경에서 domain 설정
const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost;
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
response.cookies.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "strict",
domain: domain,
domain: getCookieDomain(),
path: "/",
});
}
@@ -91,7 +85,6 @@ async function handler(
}
}
export async function POST(
request: Request,
props: { params: Promise<{ url: string[] }> },
@@ -107,3 +100,14 @@ export async function PUT(
const params = await props.params;
return handler(request, params, "PUT");
}
function getCookieDomain(): string | undefined {
if (typeof window === "undefined") return undefined;
if (COOKIE_DOMAIN) return COOKIE_DOMAIN;
const { hostname } = window.location;
if (hostname === "localhost" || hostname === "127.0.0.1") return undefined;
return hostname;
}
+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
```
+262
View File
@@ -0,0 +1,262 @@
# 화면설계서: 알림톡템플릿관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | -------------------------------------------- |
| 버전 | 1.0 |
| 프로세스명 | UI설계 |
| 화면명 | 알림톡템플릿관리 |
| 화면 유형 | [x] 조회 [x] 입력 [x] 삭제 [ ] 수정 [ ] 출력 |
| 메뉴 경로 | home > 시스템관리 > 알림톡템플릿관리 |
---
## 2. 화면 목적
카카오 알림톡(또는 동일 채널) **템플릿 목록을 조회·관리**하고, 선택한 템플릿에 연결된 **치환 변수 목록**을 확인·관리한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ 페이지 제목: 알림톡템플릿관리 브레드크럼 (우측 상단) │
├──────────────────────────────────────────────────────────────────────────────┤
│ [영역 A] 검색 필터 │
│ · 1행: 템플릿코드, 템플릿명, 템플릿내용 (텍스트) │
│ · 2행: 채널, 검수상태, 메시지유형 (드롭다운, 기본값: 전체) | 조회, 초기화 │
├──────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 알림톡 템플릿 관리 │
│ · 섹션 제목 + 툴바(템플릿갱신, 템플릿등록, 선택 검수요청, 선택삭제, 저장) │
│ · 템플릿 그리드 (체크박스 다중 선택) │
├──────────────────────────────────────────────────────────────────────────────┤
│ [영역 C] 변수관리 │
│ · 섹션 제목 + 툴바(변수추가, 선택삭제, 저장) │
│ · 변수 그리드 │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## 4. 영역 A: 검색 필터
### 4-1. 필드 정의
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ---------- | --------- | ---- | ------ | ------------------ | --------------------------------------- |
| 템플릿코드 | 텍스트 | N | 공백 | `templateCode` | |
| 템플릿명 | 텍스트 | N | 공백 | `templateName` | |
| 템플릿내용 | 텍스트 | N | 공백 | - | 현재 `NtftalkTempSearchParams`에 미정의 |
| 채널 | 드롭다운 | N | 전체 | `senderKey` | |
| 검수상태 | 드롭다운 | N | 전체 | `inspectionStatus` | |
| 메시지유형 | 드롭다운 | N | 전체 | - | 현재 `NtftalkTempSearchParams`에 미정의 |
### 4-2. 버튼
| 버튼명 | 동작 |
| ------ | ----------------------------------------------------------------------- |
| 조회 | 현재 필터 조건으로 영역 B 목록을 재조회한다. 페이지는 1페이지로 초기화. |
| 초기화 | 검색 필드를 기본값(공백·전체)으로 되돌린다. |
---
## 5. 영역 B: 알림톡 템플릿 관리
### 5-1. 툴바 버튼
| 버튼명 | 동작(요구사항) |
| ------------- | ------------------------------------------------------- |
| 템플릿갱신 | API를 재조회하여 템플릿 목록을 갱신한다. |
| 템플릿등록 | 신규 템플릿 등록 다이얼로그를 오픈한다. |
| 선택 검수요청 | 체크된 행에 대해 검수 요청 API를 호출한다. |
| 선택삭제 | 체크된 행을 삭제한다. 데이터베이스에는 삭제되지 않는다. |
| 저장 | 그리드에서 변경된 내용을 일괄 저장한다. |
※ 실제 API·권한은 백엔드 정책에 따른다.
### 5-2. 그리드 컬럼
| 표시명 | Field | 비고 |
| ---------- | ----------------------- | ----------------------------------------- |
| (선택) | — | 체크박스, 다중 선택 |
| @채널 | `senderKey` | |
| 템플릿코드 | `templateCode` | |
| 템플릿명 | `templateName` | |
| 메시지유형 | `templateMessageType` | |
| 템플릿내용 | `templateContent` | 긴 텍스트, 말줄임 처리 |
| 강조유형 | `templateEmphasizeType` | |
| 버튼정보 | `buttons` | |
| 검수상태 | `inspectionStatus` | |
| 상태 | `status` | |
| 등록일자 | `createdAt` | ISO datetime 문자열 (YYYY-MM-DD HH:mm:ss) |
### 5-3. 행 상호작용
- 행 **클릭** 시: 해당 템플릿의 상세 다이얼로그를 오픈한다.
- 다중 체크는 **삭제·검수요청** 등 일괄 작업용으로 사용한다.
---
## 6. 영역 C: 변수관리
알림톡 템플릿에 사용되는 공통 변수 목록을 관리한다.
### 6-1. 툴바 버튼
| 버튼명 | 동작 |
| -------- | ----------------------------- |
| 변수추가 | 변수 행 추가(인라인). |
| 선택삭제 | 선택된 변수 행 삭제. |
| 저장 | 추가·수정·삭제 반영 API 호출. |
### 6-2. 그리드 컬럼
| 표시명 | Field | 비고 |
| -------- | --------- | ----------------------------------------- |
| 순번 | — | 화면 표시용 row index(서버 필드 없음) |
| 변수코드 | `varCd` | |
| 변수명 | `varNm` | |
| 설명 | `varDesc` | |
| 등록일자 | `regDt` | ISO datetime 문자열 (YYYY-MM-DD HH:mm:ss) |
| 등록자 | `regr` | |
---
## 7. API/DTO 명세
### 7-1. 공통
- Base Endpoint Prefix: `/ntftalk-service/api/v1`
### 7-2. 템플릿 API
Base: `ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP`(`/ntftalk-service/api/v1/ntftalk-temp`)
| 기능 | Method | URL | Request DTO | Response DTO | API |
| ---------------- | ------ | ------------------------------ | ------------------------------------------------------ | --------------------- | -------------------- |
| 템플릿 목록 조회 | GET | `/ntftalk-temp` | `NtftalkTempSearchParams` | `NtftalkTempPageList` | `getNtftalkTempPage` |
| 템플릿 상세 조회 | GET | `/ntftalk-temp/{templateCode}` | Path: `templateCode` | `NtftalkTemp` | `getNtftalkTemp` |
| 템플릿 등록 | POST | `/ntftalk-temp` | `NtftalkTempSavePayload` | `NtftalkTemp` | `saveNtftalkTemp` |
| 템플릿 수정 | PUT | `/ntftalk-temp/{templateCode}` | Path: `templateCode`, Body: `NtftalkTempUpdatePayload` | `NtftalkTemp` | `updateNtftalkTemp` |
| 템플릿 삭제 | DELETE | `/ntftalk-temp/{templateCode}` | Path: `templateCode` | 없음 | `deleteNtftalkTemp` |
### 7-3. 변수 API
Base: `ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR`(`/ntftalk-service/api/v1/ntftalk-var`)
| 기능 | Method | URL | Request DTO | Response DTO | API |
| -------------- | ------ | ---------------------- | ---------------------------------------------- | -------------------- | ------------------- |
| 변수 목록 조회 | GET | `/ntftalk-var` | `NtftalkVarSearchParams` | `NtftalkVarPageList` | `getNtftalkVarPage` |
| 변수 상세 조회 | GET | `/ntftalk-var/{varCd}` | Path: `varCd` | `NtftalkVar` | `getNtftalkVar` |
| 변수 등록 | POST | `/ntftalk-var` | `NtftalkVarSavePayload` | `NtftalkVar` | `saveNtftalkVar` |
| 변수 수정 | PUT | `/ntftalk-var/{varCd}` | Path: `varCd`, Body: `NtftalkVarUpdatePayload` | `NtftalkVar` | `updateNtftalkVar` |
| 변수 삭제 | DELETE | `/ntftalk-var/{varCd}` | Path: `varCd` | 없음 | `deleteNtftalkVar` |
---
## 8. UI 구현 가이드
### 8-1. 기술 스택·원칙
- **UI**: `@common/components` 공통 컴포넌트 사용
- **테이블**: `@tanstack/react-table` + `useDataTable`(`src/shared/lib/use-data-table.tsx`)
- **데이터**: `@tanstack/react-query`(등록/수정 폼에 검증이 필요하면 `react-hook-form` + `zod` 사용)
- **레이아웃**: App Router `app/(with-base-layout)/.../page.tsx`에서 페이지 UI 컴포넌트만 마운트
### 8-2. 모듈·파일 배치
| 구분 | 경로 |
| ------------- | ----------------------------------------------------------------------- |
| 라우트 엔트리 | `apps/cs/app/(with-base-layout)/ntftalk-temp/page.tsx` |
| 페이지 셸 | `apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx` |
| 컬럼 정의 | `apps/cs/src/pages/ntftalk-temp/model/*-table-columns.tsx` |
| API·타입 | `apps/cs/src/entities/ntftalk/model/*-interface.ts`, `api/*-service.ts` |
| 다이얼로그 | `apps/cs/src/pages/ntftalk-temp/ui/*-dialog.tsx` |
### 8-3. 영역별 UI 매핑
| 영역 | 화면 요소 | 권장 컴포넌트·훅 | 구현 비고 |
| ---- | ------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| A | 검색 폼 | `Input`, `Label`, `Select`, `Button` | |
| B | 템플릿 그리드 | `useDataTable` + `ColumnDef`, `RowSelectionState`(컬럼 선택) | `manualPagination`, `onRowClick` → **8-4** 상세 다이얼로그 |
| B | 툴바 | `Button` 그룹 | 검수요청·삭제 등 확인은 `useConfirm` + `src/shared/ui/confirm-dialog.tsx` 등 기존 패턴 재사용 |
| C | 변수 그리드 | `useDataTable` + `ColumnDef`, `Input` + `zod` | **변수추가는 인라인**(6-1절). 선택 행 삭제·저장은 변수 API(7-3절)와 매핑. 검증은 `zod` 사용 |
| 공통 | 로딩·빈 목록 | `LoadingWithAnimation`, 빈 데이터 안내 문구 | 기타 목록 페이지와 동일 수준 처리 |
### 8-4. 다이얼로그·라우팅
| UX | 구현 단위 | 컨테이너·비고 |
| --------------------------------- | ------------------------- | ---------------------------------------------------------------------------- |
| 템플릿등록 (5-1) | `NtftalkTempRegistDialog` | `Dialog` + 폼. `saveNtftalkTemp`(7-2) |
| 행 클릭 상세 (5-3) | `NtftalkTempDetailDialog` | **별도 라우트가 아닌 다이얼로그**로 구현. `getNtftalkTemp`(7-2)로 상세 로드 |
| 선택 검수요청·선택삭제·저장 (5-1) | mutation + confirm | 각각 대응 API(7-2) 및 백엔드 정책에 따름. 미확정 엔드포인트는 **TBD**로 표기 |
### 8-5. 데이터 흐름
```
BEGIN
// 최초 진입
CALL 템플릿목록조회(기본 검색 조건)
DISPLAY 알림톡템플릿그리드 (영역 B)
CALL 변수목록조회(검색 조건)
DISPLAY 변수그리드 (영역 C)
// 영역 A: 조회·초기화
ON [조회] 버튼 CLICK DO
CALL 템플릿목록조회(템플릿코드, 템플릿명, 템플릿내용, 채널, 검수상태, 메시지유형) // → 영역 B 갱신, 페이지 1로 초기화
END ON
ON [초기화] 버튼 CLICK DO
RESET 검색 조건 → 기본값(공백·전체)
END ON
// 영역 B: 알림톡 템플릿 (7-2절 참고)
ON [템플릿갱신] CLICK DO
CALL 템플릿목록조회(현재 검색 조건) // → 영역 B 갱신
END ON
ON [템플릿등록] CLICK DO
OPEN 템플릿등록 다이얼로그
END ON
ON 영역 B 행 CLICK DO
CALL 템플릿상세조회(선택된 templateCode)
DISPLAY 템플릿상세 다이얼로그
END ON
ON [선택 검수요청] CLICK DO
CONFIRM 사용자
CALL 검수요청 API(체크된 행)
END ON
ON [선택삭제] CLICK DO
CONFIRM 사용자
CALL 템플릿삭제 API(체크된 행)
END ON
ON [저장] CLICK DO
CALL 템플릿 일괄저장 API(그리드 변경분)
END ON
// 영역 C: 변수 (7-3절 참고)
ON [변수추가] CLICK DO
ADD 변수 행 (인라인 편집)
END ON
ON [선택삭제] CLICK DO (영역 C)
CONFIRM 사용자
CALL 변수삭제 API(선택 행)
END ON
ON [저장] CLICK DO (영역 C)
CALL 변수 등록·수정 API(변경분)
END ON
END
```
+3
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,9 +12,11 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
...baseEsLintConfig,
{
rules: {
"react-hooks/exhaustive-deps": "off",
"@typescript-eslint/no-unused-vars": "warn",
},
},
];
+4 -4
View File
@@ -37,7 +37,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
// console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl));
return addSecurityHeaders(response);
@@ -54,7 +54,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
// console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
@@ -79,7 +79,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Link API Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
// console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
@@ -93,7 +93,7 @@ export function middleware(req: NextRequest) {
}
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
// console.log(logMessages.join("\n"));
const response = NextResponse.next();
return addSecurityHeaders(response);
+3 -1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/config": "workspace:*",
"@fontsource-variable/noto-sans-kr": "^5.2.5",
@@ -53,6 +54,7 @@
"@types/webpack": "^5.28.5",
"eslint": "^9.0.0",
"eslint-config-next": "15.3.8",
"eslint-plugin-react-hooks": "^5.2.0",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
@@ -60,4 +62,4 @@
"tailwindcss": "^3.4.14",
"typescript": "^5.7.2"
}
}
}
+2 -1
View File
@@ -14,8 +14,9 @@ import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog";
import { PromptProvider, StoreProvider } from "@/shared/providers";
const queryClient = new QueryClient();
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
@@ -0,0 +1,105 @@
import { getRemainingMs } from "@common/auth";
import {
ENDPOINT,
CLAIM_NAME,
ACCESS_TOKEN,
AUTH_USER_ID,
} from "@/shared/config";
import type { ILogin } from "../model/auth-interface";
import {
setToken,
clearAuthToken,
setupRefreshTimer,
} from "../lib/token-manager";
function resolveLoginError(message: string | undefined): string {
switch (message) {
case "Invalid Credentials":
return "아이디와 비밀번호가 일치하지 않습니다.";
case "User not found":
return "존재하지 않는 아이디입니다.";
case "User is not active":
return "활성화되지 않은 계정입니다.";
case "User is not verified":
return "이메일 인증이 필요합니다.";
default:
return message ?? "로그인에 실패했습니다.";
}
}
export async function login(payload: ILogin): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
throw new Error(resolveLoginError(data?.message));
}
const accessToken = data[ACCESS_TOKEN] as string | undefined;
const tokenId = data[AUTH_USER_ID] as string | undefined;
if (!accessToken || !tokenId) {
throw new Error("인증 토큰을 받지 못했습니다.");
}
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
}
export async function silentRefresh(): Promise<void> {
console.log(`[silentRefresh] Called at ${new Date().toISOString()}`);
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
);
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
}
const accessToken = data[ACCESS_TOKEN] as string | undefined;
const tokenId = data[AUTH_USER_ID] as string | undefined;
if (!accessToken || !tokenId) {
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
}
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
} catch (error) {
console.error(
`[silentRefresh] Failed at ${new Date().toISOString()}`,
error,
);
clearAuthToken();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
}
// re-throw하지 않음: setupRefreshTimer 콜백의 catch와 이중 처리 방지
}
}
export async function logout(): Promise<void> {
clearAuthToken();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
}
}
@@ -1,96 +0,0 @@
import { AxiosError } from "axios";
import {
ENDPOINT,
JWT_EXPIRATION_TIME,
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
} from "@/shared/config";
import type { LoginService, ILogin } from "../model/login-interface";
import { tokenManager } from "../lib/token-manager";
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
private constructor() {}
public static getInstance(): LoginApiService {
if (!LoginApiService.instance) {
LoginApiService.instance = new LoginApiService();
}
return LoginApiService.instance;
}
public async login(payload: ILogin): Promise<void> {
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
password: payload.password,
}),
});
const data = await response.json();
console.log('data ', data)
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
if (data.message == 'Invalid Credentials' ) {
throw new Error('아이디와 비밀번호가 일치하지 않습니다.');
} else if (data.message == 'User not found') {
throw new Error('존재하지 않는 아이디입니다.');
} else if (data.message == 'User is not active') {
throw new Error('활성화되지 않은 계정입니다.');
} else if (data.message == 'User is not verified') {
throw new Error('이메일 인증이 필요합니다.');
} else {
throw new Error(data.message);
}
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setupRefreshTimer(
() => this.silentRefresh(),
JWT_EXPIRATION_TIME,
);
} catch (error) {
if (error instanceof AxiosError) {
throw new Error(`Login 실패 ${error.response?.data?.message}`);
}
throw new Error(`Login 실패 ${error}`);
}
}
public async silentRefresh(): Promise<void> {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
);
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
} catch (error) {
tokenManager.clearAuthToken();
if (error instanceof AxiosError) {
throw new Error(
`Silent refresh failed: ${error.response?.data?.message}`,
);
}
throw new Error(`Silent refresh failed: ${error}`);
}
}
}
export const LoginApi = LoginApiService.getInstance();
+2 -1
View File
@@ -1,2 +1,3 @@
export { LoginApi } from "./api/login-service";
export { login, silentRefresh, logout } from "./api/auth-service";
export { AuthWrapper } from "./lib/auth-wrapper";
export { tokenManager } from "./lib/token-manager";
+62 -29
View File
@@ -1,47 +1,80 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { isAxiosError } from "axios";
import { getRemainingMs } from "@common/auth";
import { isAtLoginRedirectUrl } from "@common/network";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
import { isAtLoginRedirectUrl } from "@common/network";
import { useUserStore } from "@/shared/lib";
import {
ENDPOINT,
AUTH_USER_ID,
AUTH_LOGIN_REDIRECT_URL,
CLAIM_NAME,
} from "@/shared/config";
import { getToken, setupRefreshTimer } from "./token-manager";
import { silentRefresh } from "../api/auth-service";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
return;
}
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
}
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
);
if (!userInfo) setUserInfo(response.data);
}
const router = useRouter();
const userInfo = useUserStore((state) => state.userInfo);
const setUserInfo = useUserStore((state) => state.setUserInfo);
useEffect(() => {
(async () => {
try {
await authCheck();
} catch (error) {
console.error(error);
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
window.location.assign(AUTH_LOGIN_REDIRECT_URL);
}
// 페이지 새로고침 시 silentRefresh 타이머 복구
const token = getToken(CLAIM_NAME);
if (token) {
const remainingMs = getRemainingMs(token);
if (remainingMs > 0) {
console.log(
`[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`,
);
setupRefreshTimer(silentRefresh, remainingMs);
}
})();
}
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return;
async function authCheck() {
const userId = getToken(AUTH_USER_ID);
if (!userId) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
return;
}
if (userInfo) return;
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
);
setUserInfo(response.data);
}
authCheck().catch((error) => {
// 401은 @common/network error-interceptor에서 이미 리다이렉트 처리됨
if (isAxiosError(error) && error.response?.status === 401) return;
console.error(error);
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
}
});
}, []);
// silentRefresh 실패 시 auth:logout 이벤트를 수신해 로그인으로 이동
useEffect(() => {
function handleAuthLogout() {
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
}
}
window.addEventListener("auth:logout", handleAuthLogout);
return () => window.removeEventListener("auth:logout", handleAuthLogout);
}, []);
return <>{children}</>;
+13 -80
View File
@@ -1,82 +1,15 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
import { getCookie, setCookie, removeCookie } from "@/shared/lib";
import { createTokenManager } from "@common/auth";
import { authConfig } from "@/shared/config/auth";
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
export const tokenManager = createTokenManager(authConfig);
private constructor() {}
public static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
public getToken(key: string): string | undefined {
return getCookie(key);
}
public setToken(key: string, value: string): void {
const domain = this.getCookieDomain();
setCookie(key, value, {
secure: window.location.protocol === "https:",
sameSite: "lax",
domain: domain,
path: "/", // 모든 경로에서 접근 가능
});
}
private getCookieDomain(): string | undefined {
const hostname = window.location.hostname;
// 개발 환경에서는 localhost 도메인 사용
if (hostname === "localhost" || hostname === "127.0.0.1") {
return undefined; // localhost에서는 도메인 설정하지 않음
}
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
if (hostname.includes("hasuon.or.kr")) {
return ".hasuon.or.kr";
}
// 기타 환경에서는 현재 호스트명 사용
return hostname;
}
public removeToken(key: string): void {
const domain = this.getCookieDomain();
removeCookie(key, { domain, path: "/" });
}
public setupRefreshTimer(
callback: () => Promise<void>,
expirationTime: number,
): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
callback().catch((error) => {
console.error("Silent refresh failed:", error);
this.clearAuthToken();
});
}, expirationTime - 60000);
}
public clearAuthToken(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
}
}
export const tokenManager = TokenManager.getInstance();
export const {
getToken,
setToken,
removeToken,
setupRefreshTimer,
clearAuthToken,
getAccessToken,
getRefreshToken,
getUserId,
} = tokenManager;
@@ -0,0 +1,4 @@
export interface ILogin {
userId: string;
password: string;
}
@@ -1,9 +0,0 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
@@ -44,7 +44,6 @@ export const LwpoCodeFormSchema = z.object({
prntLwpoCd: z.string().max(10).nullish(),
useYn: z.string(),
sortSeq: z.number(),
delYn: z.string(),
});
export type LwpoCodeFormData = z.infer<typeof LwpoCodeFormSchema>;
@@ -32,8 +32,11 @@ export interface UpCodeDetail extends UpCodeFormData {
}
export const upCodeFormSchema = z.object({
upCd: z.string().max(10).nonempty(),
upCdNm: z.string().max(100).nonempty(),
upCd: z
.string()
.max(30, "최대 30자까지 입력할 수 있습니다.")
.nonempty("최소 1자 이상 입력해주세요."),
upCdNm: z.string().max(100).nonempty("최소 1자 이상 입력해주세요."),
upCdExpln: z.string().max(1000).nullish(),
cdSe: z.string().max(10).nonempty(),
siteId: z.string().max(2).nullish(),
@@ -0,0 +1,64 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
type NtftalkBiztalkCheckInspectionResponse,
type NtftalkBiztalkInspectionRequestItem,
type NtftalkBiztalkInspectionResponse,
type NtftalkBiztalkSender,
type NtftalkBiztalkService,
type NtftalkBiztalkSyncTemplatesParams,
type NtftalkBiztalkSyncTemplatesResponse,
} from "../model/ntftalk-biztalk-interface";
export const NtftalkBiztalkApi: NtftalkBiztalkService = {
async getSender(): Promise<NtftalkBiztalkSender> {
const response = await client.get<NtftalkBiztalkSender>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/sender`,
);
return response.data;
},
async syncTemplates(
params?: NtftalkBiztalkSyncTemplatesParams,
): Promise<NtftalkBiztalkSyncTemplatesResponse> {
const response = await client.post<NtftalkBiztalkSyncTemplatesResponse>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/sync-templates`,
undefined,
{
params: {
senderKeyType: params?.senderKeyType,
},
},
);
return response.data;
},
async requestInspection(
requestList: NtftalkBiztalkInspectionRequestItem[],
): Promise<NtftalkBiztalkInspectionResponse> {
const response = await client.post<NtftalkBiztalkInspectionResponse>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/request-inspection`,
requestList,
);
return response.data;
},
async cancelInspection(
requestList: NtftalkBiztalkInspectionRequestItem[],
): Promise<NtftalkBiztalkInspectionResponse> {
const response = await client.post<NtftalkBiztalkInspectionResponse>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/cancel-inspection`,
requestList,
);
return response.data;
},
async checkInspection(
templateCode: string,
): Promise<NtftalkBiztalkCheckInspectionResponse> {
const response = await client.get<NtftalkBiztalkCheckInspectionResponse>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/check-inspection/${encodeURIComponent(templateCode)}`,
);
return response.data;
},
};
@@ -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();
@@ -0,0 +1,56 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
type NtftalkTemp,
type NtftalkTempPageList,
type NtftalkTempSavePayload,
type NtftalkTempSearchParams,
type NtftalkTempService,
type NtftalkTempUpdatePayload,
} from "../model/ntftalk-temp-interface";
class NtftalkTempApiService implements NtftalkTempService {
async getNtftalkTempPage(
params: NtftalkTempSearchParams,
): Promise<NtftalkTempPageList> {
const response = await client.get<NtftalkTempPageList>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}`,
{ params },
);
return response.data;
}
async getNtftalkTemp(templateCode: string): Promise<NtftalkTemp> {
const response = await client.get<NtftalkTemp>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/${encodeURIComponent(templateCode)}`,
);
return response.data;
}
async saveNtftalkTemp(payload: NtftalkTempSavePayload): Promise<NtftalkTemp> {
const response = await client.post<NtftalkTemp>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}`,
payload,
);
return response.data;
}
async updateNtftalkTemp(
templateCode: string,
payload: NtftalkTempUpdatePayload,
): Promise<NtftalkTemp> {
const response = await client.put<NtftalkTemp>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/${encodeURIComponent(templateCode)}`,
payload,
);
return response.data;
}
async deleteNtftalkTemp(templateCode: string): Promise<void> {
await client.delete(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/${encodeURIComponent(templateCode)}`,
);
}
}
export const NtftalkTempApi = new NtftalkTempApiService();
@@ -0,0 +1,56 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
type NtftalkVar,
type NtftalkVarPageList,
type NtftalkVarSavePayload,
type NtftalkVarSearchParams,
type NtftalkVarService,
type NtftalkVarUpdatePayload,
} from "../model/ntftalk-var-interface";
class NtftalkVarApiService implements NtftalkVarService {
async getNtftalkVarPage(
params: NtftalkVarSearchParams,
): Promise<NtftalkVarPageList> {
const response = await client.get<NtftalkVarPageList>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}`,
{ params },
);
return response.data;
}
async getNtftalkVar(varCd: string): Promise<NtftalkVar> {
const response = await client.get<NtftalkVar>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}/${encodeURIComponent(varCd)}`,
);
return response.data;
}
async saveNtftalkVar(payload: NtftalkVarSavePayload): Promise<NtftalkVar> {
const response = await client.post<NtftalkVar>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}`,
payload,
);
return response.data;
}
async updateNtftalkVar(
varCd: string,
payload: NtftalkVarUpdatePayload,
): Promise<NtftalkVar> {
const response = await client.put<NtftalkVar>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}/${encodeURIComponent(varCd)}`,
payload,
);
return response.data;
}
async deleteNtftalkVar(varCd: string): Promise<void> {
await client.delete(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}/${encodeURIComponent(varCd)}`,
);
}
}
export const NtftalkVarApi = new NtftalkVarApiService();
+37
View File
@@ -0,0 +1,37 @@
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,
NtftalkVarSavePayload,
NtftalkVarSearchParams,
NtftalkVarUpdatePayload,
} from "./model/ntftalk-var-interface";
export type {
NtftalkBiztalkCheckInspectionResponse,
NtftalkBiztalkInspectionRequestItem,
NtftalkBiztalkInspectionResponse,
NtftalkBiztalkSender,
NtftalkBiztalkSyncTemplatesParams,
NtftalkBiztalkSyncTemplatesResponse,
} from "./model/ntftalk-biztalk-interface";
export type {
NtftalkBtnTemp,
NtftalkBtnTempSavePayload,
NtftalkQuickTemp,
NtftalkQuickTempSavePayload,
NtftalkTemp,
NtftalkTempPageList,
NtftalkTempSavePayload,
NtftalkTempSearchParams,
NtftalkTempUpdatePayload,
} from "./model/ntftalk-temp-interface";
export type {
NtftalkMmtLogHistory,
NtftalkMmtLogHistoryPageList,
NtftalkMmtLogHistorySavePayload,
NtftalkMmtLogHistorySearchParams,
NtftalkMmtLogHistoryUpdatePayload,
} from "./model/ntftalk-mmt-log-history-interface";
@@ -0,0 +1,48 @@
export interface NtftalkBiztalkSender {
senderKey: string;
name: string;
uuid: string;
status: string;
}
export interface NtftalkBiztalkSyncTemplatesParams {
senderKeyType?: string;
}
export interface NtftalkBiztalkSyncTemplatesResponse {
syncCount: number;
message: string;
}
export interface NtftalkBiztalkInspectionRequestItem {
senderKey: string;
templateCode: string;
senderKeyType: string;
}
export interface NtftalkBiztalkInspectionResponse {
processedCount: number;
message: string;
}
export interface NtftalkBiztalkCheckInspectionResponse {
templateCode: string;
approved: boolean;
message: string;
}
export interface NtftalkBiztalkService {
getSender(): Promise<NtftalkBiztalkSender>;
syncTemplates(
params?: NtftalkBiztalkSyncTemplatesParams,
): Promise<NtftalkBiztalkSyncTemplatesResponse>;
requestInspection(
requestList: NtftalkBiztalkInspectionRequestItem[],
): Promise<NtftalkBiztalkInspectionResponse>;
cancelInspection(
requestList: NtftalkBiztalkInspectionRequestItem[],
): Promise<NtftalkBiztalkInspectionResponse>;
checkInspection(
templateCode: string,
): Promise<NtftalkBiztalkCheckInspectionResponse>;
}
@@ -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>;
}
@@ -0,0 +1,110 @@
import type { PageableList, PageableParams } from "@/shared/model";
export interface NtftalkTempSearchParams extends PageableParams {
templateCode?: string;
templateName?: string;
/** 백엔드 스펙 확정 시 전달 */
templateContent?: string;
inspectionStatus?: string;
senderKey?: string;
status?: string;
/** 백엔드 스펙 확정 시 전달 */
templateMessageType?: string;
}
export interface NtftalkBtnTemp {
templateCode: string;
name: string;
ordering: number;
linkType: string;
linkMo: string;
linkPc: string;
linkAnd: string;
linkIos: string;
}
export interface NtftalkQuickTemp {
templateCode: string;
name: string;
linkType: string;
linkMo: string;
linkPc: string;
linkAnd: string;
linkIos: string;
}
export interface NtftalkTemp {
templateCode: string;
senderKey: string;
senderKeyType: string;
templateName: string;
templateContent: string;
templateMessageType: string;
templateEmphasizeType: string;
templateImageUrl: string;
inspectionStatus: string;
status: string;
createdAt: string;
modifiedAt: string;
buttons?: NtftalkBtnTemp[];
quickReplies?: NtftalkQuickTemp[];
}
export interface NtftalkBtnTempSavePayload {
templateCode?: string;
name: string;
ordering: number;
linkType: string;
linkMo?: string;
linkPc?: string;
linkAnd?: string;
linkIos?: string;
}
export interface NtftalkQuickTempSavePayload {
templateCode?: string;
name: string;
linkType: string;
linkMo?: string;
linkPc?: string;
linkAnd?: string;
linkIos?: string;
}
export interface NtftalkTempSavePayload {
templateCode: string;
senderKey: string;
senderKeyType: string;
templateName: string;
templateContent: string;
templateMessageType: string;
templateEmphasizeType: string;
templateImageUrl?: string;
inspectionStatus?: string;
buttons?: NtftalkBtnTempSavePayload[];
quickReplies?: NtftalkQuickTempSavePayload[];
}
export interface NtftalkTempUpdatePayload {
templateName: string;
templateContent: string;
inspectionStatus?: string;
status?: string;
buttons?: NtftalkBtnTempSavePayload[];
quickReplies?: NtftalkQuickTempSavePayload[];
}
export type NtftalkTempPageList = PageableList<NtftalkTemp>;
export interface NtftalkTempService {
getNtftalkTempPage(
params: NtftalkTempSearchParams,
): Promise<NtftalkTempPageList>;
getNtftalkTemp(templateCode: string): Promise<NtftalkTemp>;
saveNtftalkTemp(payload: NtftalkTempSavePayload): Promise<NtftalkTemp>;
updateNtftalkTemp(
templateCode: string,
payload: NtftalkTempUpdatePayload,
): Promise<NtftalkTemp>;
deleteNtftalkTemp(templateCode: string): Promise<void>;
}
@@ -0,0 +1,42 @@
import type { PageableList, PageableParams } from "@/shared/model";
export interface NtftalkVarSearchParams extends PageableParams {
varCd?: string;
varNm?: string;
regr?: string;
}
export interface NtftalkVar {
varCd: string;
sender: string;
varNm: string;
varDesc: string;
regDt: string;
regr: string;
}
export interface NtftalkVarSavePayload {
varCd: string;
sender?: string;
varNm: string;
varDesc?: string;
regr?: string;
}
export interface NtftalkVarUpdatePayload {
varNm: string;
varDesc?: string;
}
export type NtftalkVarPageList = PageableList<NtftalkVar>;
export interface NtftalkVarService {
getNtftalkVarPage(params: NtftalkVarSearchParams): Promise<NtftalkVarPageList>;
getNtftalkVar(varCd: string): Promise<NtftalkVar>;
saveNtftalkVar(payload: NtftalkVarSavePayload): Promise<NtftalkVar>;
updateNtftalkVar(
varCd: string,
payload: NtftalkVarUpdatePayload,
): Promise<NtftalkVar>;
deleteNtftalkVar(varCd: string): 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,
@@ -3,13 +3,13 @@
import { useState } from "react";
import Image from "next/image";
import { images } from "@common/assets";
import { LoginApi } from "@/entities/auth";
import { login } from "@/entities/auth";
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(""); // 에러 메시지 초기화
@@ -18,7 +18,7 @@ function LoginPage() {
const password = formData.get("password") as string;
try {
await LoginApi.login({ userId, password });
await login({ userId, password });
window.location.href = "/dashboard";
} catch (error) {
const message =
+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,
@@ -103,7 +103,6 @@ const LwpoCodeManagementDialog = memo(
prntLwpoCd: detailData?.prntLwpoCd ?? "",
useYn: detailData?.useYn ?? "Y",
sortSeq: detailData?.sortSeq ?? 0,
delYn: "N",
},
});
@@ -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,
+9 -1
View File
@@ -31,7 +31,13 @@ import {
import { SiteSelect } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { Form, FormField, FormItem, FormControl } from "@/shared/ui/form";
import {
Form,
FormField,
FormItem,
FormControl,
FormMessage,
} from "@/shared/ui/form";
type UpCodeManagementDialogProps = {
upCd?: string;
@@ -139,6 +145,7 @@ const UpCodeManagementDialog = memo(
disabled={!isRegistration}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -160,6 +167,7 @@ const UpCodeManagementDialog = memo(
placeholder="상위코드명을 입력해주세요"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
+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
View File
@@ -0,0 +1 @@
export { NtftalkTempPage } from "./ui/ntftalk-temp-page";
@@ -0,0 +1,14 @@
import { z } from "zod";
export const ntftalkTempSaveFormSchema = z.object({
templateCode: z.string().min(1, "템플릿코드를 입력하세요."),
senderKey: z.string().min(1, "채널(senderKey)을 입력하세요."),
senderKeyType: z.string().min(1, "발신키 유형을 입력하세요."),
templateName: z.string().min(1, "템플릿명을 입력하세요."),
templateContent: z.string().min(1, "템플릿내용을 입력하세요."),
templateMessageType: z.string().min(1, "메시지유형을 입력하세요."),
templateEmphasizeType: z.string().min(1, "강조유형을 입력하세요."),
templateImageUrl: z.string().optional(),
});
export type NtftalkTempSaveFormData = z.infer<typeof ntftalkTempSaveFormSchema>;
@@ -0,0 +1,19 @@
import type { NtftalkVar } from "@/entities/ntftalk";
export interface NtftalkVarGridRow extends NtftalkVar {
clientId?: string;
isDraft?: boolean;
}
export function createEmptyVarDraft(clientId: string): NtftalkVarGridRow {
return {
clientId,
isDraft: true,
varCd: "",
varNm: "",
varDesc: "",
sender: "",
regDt: "",
regr: "",
};
}
@@ -0,0 +1,99 @@
import type { ColumnDef, Row, Table } from "@tanstack/react-table";
import { Checkbox } from "@common/components";
import type { NtftalkTemp } from "@/entities/ntftalk";
function formatButtons(row: NtftalkTemp): string {
if (!row.buttons?.length) return "—";
return row.buttons.map((b) => b.name).join(", ");
}
export function createTemplateColumns(): ColumnDef<NtftalkTemp>[] {
return [
{
id: "select",
header: ({ table }: { table: Table<NtftalkTemp> }) => (
<div
className="flex justify-center"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="전체 선택"
/>
</div>
),
cell: ({ row }: { row: Row<NtftalkTemp> }) => (
<div
className="flex justify-center"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="행 선택"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "senderKey",
header: "@채널",
},
{
accessorKey: "templateCode",
header: "템플릿코드",
},
{
accessorKey: "templateName",
header: "템플릿명",
},
{
accessorKey: "templateMessageType",
header: "메시지유형",
},
{
accessorKey: "templateContent",
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: "templateEmphasizeType",
header: "강조유형",
},
{
id: "buttons",
header: "버튼정보",
cell: ({ row }) => formatButtons(row.original),
},
{
accessorKey: "inspectionStatus",
header: "검수상태",
},
{
accessorKey: "status",
header: "상태",
},
{
accessorKey: "createdAt",
header: "등록일자",
},
];
}
@@ -0,0 +1,130 @@
import type { ColumnDef, Row, Table } from "@tanstack/react-table";
import { Checkbox, Input } from "@common/components";
import type { NtftalkVarGridRow } from "./ntftalk-var-grid";
export interface VarColumnHandlers {
onDraftFieldChange: (
clientId: string,
field: "varCd" | "varNm" | "varDesc" | "sender",
value: string,
) => void;
}
export function createVarColumns(
handlers: VarColumnHandlers,
): ColumnDef<NtftalkVarGridRow>[] {
return [
{
id: "select",
header: ({ table }: { table: Table<NtftalkVarGridRow> }) => (
<div
className="flex justify-center"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="전체 선택"
/>
</div>
),
cell: ({ row }: { row: Row<NtftalkVarGridRow> }) => (
<div
className="flex justify-center"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="행 선택"
/>
</div>
),
enableSorting: false,
},
{
id: "seq",
header: "순번",
cell: ({ row, table }) => {
const pageIndex = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return pageIndex * pageSize + row.index + 1;
},
},
{
accessorKey: "varCd",
header: "변수코드",
cell: ({ row }) => {
const r = row.original;
if (r.isDraft && r.clientId) {
return (
<Input
className="h-[3rem] min-w-[10rem]"
value={r.varCd}
onChange={(e) =>
handlers.onDraftFieldChange(r.clientId!, "varCd", e.target.value)
}
onClick={(e) => e.stopPropagation()}
/>
);
}
return r.varCd;
},
},
{
accessorKey: "varNm",
header: "변수명",
cell: ({ row }) => {
const r = row.original;
if (r.isDraft && r.clientId) {
return (
<Input
className="h-[3rem] min-w-[10rem]"
value={r.varNm}
onChange={(e) =>
handlers.onDraftFieldChange(r.clientId!, "varNm", e.target.value)
}
onClick={(e) => e.stopPropagation()}
/>
);
}
return r.varNm;
},
},
{
accessorKey: "varDesc",
header: "설명",
cell: ({ row }) => {
const r = row.original;
if (r.isDraft && r.clientId) {
return (
<Input
className="h-[3rem] min-w-[16rem]"
value={r.varDesc ?? ""}
onChange={(e) =>
handlers.onDraftFieldChange(r.clientId!, "varDesc", e.target.value)
}
onClick={(e) => e.stopPropagation()}
/>
);
}
return r.varDesc ?? "—";
},
},
{
accessorKey: "regDt",
header: "등록일자",
},
{
accessorKey: "regr",
header: "등록자",
},
];
}
@@ -0,0 +1,110 @@
"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,
DialogHeader,
DialogTitle,
LoadingWithAnimation,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
interface NtftalkTempDetailDialogProps {
templateCode: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function NtftalkTempDetailDialog({
templateCode,
open,
onOpenChange,
}: NtftalkTempDetailDialogProps) {
const query = useQuery({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP, "detail", templateCode],
queryFn: () => NtftalkTempApi.getNtftalkTemp(templateCode!),
enabled: open && !!templateCode,
});
const d = query.data;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[85vh] max-w-[96rem] overflow-y-auto"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>알림톡 템플릿 상세</DialogTitle>
</DialogHeader>
{query.isLoading && (
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
)}
{query.isError && <NoData />}
{d && (
<Table variant="secondary">
<TableBody>
<TableRow>
<TableHead>메시지 유형</TableHead>
<TableCell>{d.templateMessageType}</TableCell>
</TableRow>
<TableRow>
<TableHead>@채널</TableHead>
<TableCell>{d.senderKey}</TableCell>
</TableRow>
<TableRow>
<TableHead>템플릿코드</TableHead>
<TableCell>{d.templateCode}</TableCell>
</TableRow>
<TableRow>
<TableHead>템플릿명</TableHead>
<TableCell>{d.templateName}</TableCell>
</TableRow>
<TableRow>
<TableHead>강조유형</TableHead>
<TableCell>{d.templateEmphasizeType}</TableCell>
</TableRow>
<TableRow>
<TableHead>템플릿내용</TableHead>
<TableCell>{d.templateContent}</TableCell>
</TableRow>
<TableRow>
<TableHead>검수상태</TableHead>
<TableCell>{d.inspectionStatus}</TableCell>
</TableRow>
{/* <TableRow>
<TableHead>보안템플릿 여부</TableHead>
<TableCell>{d.templateEmphasizeType}</TableCell>
</TableRow>
<TableRow>
<TableHead>비즈톡센터 사용여부</TableHead>
<TableCell>{d.status}</TableCell>
</TableRow> */}
<TableRow>
<TableHead>등록일</TableHead>
<TableCell>{d.createdAt}</TableCell>
</TableRow>
<TableRow>
<TableHead>템플릿 상태</TableHead>
<TableCell>{d.status}</TableCell>
</TableRow>
</TableBody>
</Table>
)}
</DialogContent>
</Dialog>
);
}
export { NtftalkTempDetailDialog };
@@ -0,0 +1,678 @@
"use client";
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,
Input,
Label,
LoadingWithAnimation,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
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";
const ALL = "__ALL__";
const varDraftSaveSchema = z.object({
varCd: z.string().min(1),
varNm: z.string().min(1),
varDesc: z.string().optional(),
sender: z.string().optional(),
});
interface VarDraftFormValues {
draftVars: NtftalkVarGridRow[];
}
function NtftalkTempPage() {
const queryClient = useQueryClient();
const alert = useAlertStore((s) => s.setMessage);
const { confirm } = useConfirm();
const [templateCodeInput, setTemplateCodeInput] = useState("");
const [templateNameInput, setTemplateNameInput] = useState("");
const [templateContentInput, setTemplateContentInput] = useState("");
const [senderKeyFilter, setSenderKeyFilter] = useState(ALL);
const [inspectionFilter, setInspectionFilter] = useState(ALL);
const [messageTypeFilter, setMessageTypeFilter] = useState(ALL);
const [searchParams, setSearchParams] = useState<NtftalkTempSearchParams>({
sort: "createdAt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [varPagination, setVarPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [varSearchParams] = useState<NtftalkVarSearchParams>({
sort: "regDt,desc",
});
const [varRowSelection, setVarRowSelection] = useState<RowSelectionState>({});
const varDraftForm = useForm<VarDraftFormValues>({
defaultValues: { draftVars: [] },
});
const { control, setValue } = varDraftForm;
const { append, remove, replace } = useFieldArray({
control,
name: "draftVars",
});
const draftVars = useWatch({ control, name: "draftVars" }) ?? [];
const [detailCode, setDetailCode] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [registOpen, setRegistOpen] = useState(false);
const tempQuery = useQuery({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP, searchParams, pagination],
queryFn: () =>
NtftalkTempApi.getNtftalkTempPage({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
});
const varQuery = useQuery({
queryKey: [
ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR,
varSearchParams,
varPagination,
],
queryFn: () =>
NtftalkVarApi.getNtftalkVarPage({
...varSearchParams,
page: varPagination.pageIndex,
size: varPagination.pageSize,
}),
placeholderData: keepPreviousData,
});
const senderQuery = useQuery({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK, "sender"],
queryFn: () => NtftalkBiztalkApi.getSender(),
});
const senderKeyOptions = useMemo(() => {
if (senderQuery.data) return [senderQuery.data];
return [];
}, [senderQuery.data]);
const inspectionOptions = useMemo(() => {
const set = new Set<string>();
(tempQuery.data?.content ?? []).forEach((r) => {
if (r.inspectionStatus) set.add(r.inspectionStatus);
});
return Array.from(set).sort();
}, [tempQuery.data]);
const messageTypeOptions = useMemo(() => {
const set = new Set<string>();
(tempQuery.data?.content ?? []).forEach((r) => {
if (r.templateMessageType) set.add(r.templateMessageType);
});
return Array.from(set).sort();
}, [tempQuery.data]);
const templateColumns = useMemo(() => createTemplateColumns(), []);
const {
tableComponent: templateTableComponent,
tableInstance: templateTable,
} = useDataTable({
query: tempQuery,
tableOptions: {
columns: templateColumns,
state: { pagination, rowSelection },
manualPagination: true,
enableRowSelection: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
viewPagination: true,
onRowClick: (row: NtftalkTemp) => {
setDetailCode(row.templateCode);
setDetailOpen(true);
},
},
});
const draftIndexByClientId = useMemo(() => {
const indexByClientId = new Map<string, number>();
draftVars.forEach((row, index) => {
if (row.clientId) indexByClientId.set(row.clientId, index);
});
return indexByClientId;
}, [draftVars]);
const onDraftFieldChange = useCallback(
(
clientId: string,
field: "varCd" | "varNm" | "varDesc" | "sender",
value: string,
) => {
const draftIndex = draftIndexByClientId.get(clientId);
if (draftIndex === undefined) return;
setValue(`draftVars.${draftIndex}.${field}`, value, {
shouldDirty: true,
});
},
[draftIndexByClientId, setValue],
);
const varColumns = useMemo(
() => createVarColumns({ onDraftFieldChange }),
[onDraftFieldChange],
);
const varTableRows = useMemo((): NtftalkVarGridRow[] => {
const server = varQuery.data?.content ?? [];
if (varPagination.pageIndex === 0) return [...server, ...draftVars];
return server;
}, [varQuery.data, draftVars, varPagination.pageIndex]);
const varTable = useReactTable({
data: varTableRows,
columns: varColumns,
state: { pagination: varPagination, rowSelection: varRowSelection },
manualPagination: true,
rowCount: varQuery.data?.totalElements ?? 0,
pageCount: varQuery.data?.totalPages ?? 0,
enableRowSelection: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setVarPagination,
onRowSelectionChange: setVarRowSelection,
getRowId: (row) =>
row.isDraft && row.clientId
? `draft:${row.clientId}`
: `srv:${row.varCd}`,
});
const buildSearchParamsFromInputs = (): NtftalkTempSearchParams => ({
sort: searchParams.sort ?? "createdAt,desc",
templateCode: templateCodeInput.trim() || undefined,
templateName: templateNameInput.trim() || undefined,
templateContent: templateContentInput.trim() || undefined,
senderKey: senderKeyFilter === ALL ? undefined : senderKeyFilter,
inspectionStatus: inspectionFilter === ALL ? undefined : inspectionFilter,
templateMessageType:
messageTypeFilter === ALL ? undefined : messageTypeFilter,
});
const handleSearch = (e: React.SubmitEvent) => {
e.preventDefault();
setPagination((p) => ({ ...p, pageIndex: 0 }));
setSearchParams(buildSearchParamsFromInputs());
};
const handleReset = () => {
setTemplateCodeInput("");
setTemplateNameInput("");
setTemplateContentInput("");
setSenderKeyFilter(ALL);
setInspectionFilter(ALL);
setMessageTypeFilter(ALL);
setPagination((p) => ({ ...p, pageIndex: 0 }));
setSearchParams({ sort: "createdAt,desc" });
};
const syncTemplatesMutation = useMutationWrapper({
mutationFn: () => NtftalkBiztalkApi.syncTemplates({ senderKeyType: "S" }),
onSuccess: async (data) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP],
}),
queryClient.invalidateQueries({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK, "sender"],
}),
]);
alert(data.message);
},
});
const handleRefreshTemplates = () => {
syncTemplatesMutation.mutate();
};
const deleteTemplatesMutation = useMutationWrapper({
mutationFn: async (codes: string[]) => {
for (const code of codes) {
await NtftalkTempApi.deleteNtftalkTemp(code);
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP],
});
setRowSelection({});
alert("삭제되었습니다.");
},
});
const handleDeleteSelectedTemplates = async () => {
const rows = templateTable.getSelectedRowModel().rows;
if (rows.length === 0) {
alert("삭제할 템플릿을 선택해 주세요.");
return;
}
if (
!(await confirm(
`선택한 ${rows.length}건의 템플릿을 삭제(비표시) 처리하시겠습니까?`,
))
)
return;
deleteTemplatesMutation.mutate(rows.map((r) => r.original.templateCode));
};
const inspectionRequestMutation = useMutationWrapper({
mutationFn: (requestList: NtftalkBiztalkInspectionRequestItem[]) =>
NtftalkBiztalkApi.requestInspection(requestList),
onSuccess: async (data) => {
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP],
});
setRowSelection({});
alert(data.message);
},
});
const handleInspectionRequest = async () => {
const rows = templateTable.getSelectedRowModel().rows;
if (rows.length === 0) {
alert("검수요청할 템플릿을 선택해 주세요.");
return;
}
const requestList: NtftalkBiztalkInspectionRequestItem[] = [];
for (const row of rows) {
const { senderKey, templateCode, senderKeyType } = row.original;
if (!senderKey || !templateCode) {
alert("선택 항목에 발신키 또는 템플릿코드가 없습니다.");
return;
}
requestList.push({
senderKey,
templateCode,
senderKeyType: senderKeyType || "S",
});
}
if (
!(await confirm(`선택한 ${requestList.length}건을 검수요청하시겠습니까?`))
)
return;
inspectionRequestMutation.mutate(requestList);
};
const handleSaveTemplateGrid = () => {
alert("템플릿 그리드 셀 편집·일괄 저장은 별도 구현 시 연동합니다.");
};
const handleAddVarRow = () => {
append(createEmptyVarDraft(crypto.randomUUID()));
setVarPagination((p) => ({ ...p, pageIndex: 0 }));
};
const deleteVarsMutation = useMutationWrapper({
mutationFn: async (codes: string[]) => {
for (const code of codes) {
await NtftalkVarApi.deleteNtftalkVar(code);
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR],
});
setVarRowSelection({});
alert("삭제되었습니다.");
},
});
const handleDeleteSelectedVars = async () => {
const selected = varTable.getSelectedRowModel().rows;
if (selected.length === 0) {
alert("삭제할 변수를 선택해 주세요.");
return;
}
if (!(await confirm(`선택한 ${selected.length}건을 삭제하시겠습니까?`)))
return;
const serverCodes: string[] = [];
const draftIndexes: number[] = [];
selected.forEach((r) => {
const o = r.original;
if (o.isDraft && o.clientId) {
const draftIndex = draftIndexByClientId.get(o.clientId);
if (draftIndex !== undefined) draftIndexes.push(draftIndex);
} else if (o.varCd) serverCodes.push(o.varCd);
});
if (draftIndexes.length)
remove([...new Set(draftIndexes)].sort((a, b) => b - a));
if (serverCodes.length) deleteVarsMutation.mutate(serverCodes);
};
const saveVarsMutation = useMutationWrapper({
mutationFn: async (drafts: NtftalkVarGridRow[]) => {
const regr = getCookie(AUTH_USER_ID) ?? undefined;
for (const d of drafts) {
const parsed = varDraftSaveSchema.safeParse({
varCd: d.varCd,
varNm: d.varNm,
varDesc: d.varDesc || undefined,
sender: d.sender || undefined,
});
if (!parsed.success)
throw new Error(
parsed.error.issues[0]?.message ?? "유효하지 않은 입력입니다.",
);
await NtftalkVarApi.saveNtftalkVar({
varCd: parsed.data.varCd,
varNm: parsed.data.varNm,
varDesc: parsed.data.varDesc,
sender: parsed.data.sender,
regr,
});
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR],
});
replace([]);
setVarRowSelection({});
alert("저장되었습니다.");
},
});
const handleSaveVars = () => {
if (draftVars.length === 0) {
alert("저장할 신규 변수 행이 없습니다. 변수추가로 행을 추가하세요.");
return;
}
saveVarsMutation.mutate(draftVars);
};
const varTableComponent = (() => {
if (varQuery.isLoading)
return (
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
);
if (varQuery.isError) return <NoData />;
return <DataTable table={varTable} viewPagination />;
})();
return (
<div className="card card-border !mt-6 flex flex-col gap-4">
<div className="card card-border">
<form
onSubmit={handleSearch}
className="flex items-end justify-between p-2"
>
<div className="flex flex-col gap-4">
<div className="flex w-full gap-6">
<div className="flex items-center gap-2">
<Label className="w-[6rem] whitespace-nowrap text-label">
템플릿코드
</Label>
<Input
className="w-[20rem]"
value={templateCodeInput}
onChange={(e) => setTemplateCodeInput(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Label className="w-[6rem] whitespace-nowrap text-label">
템플릿명
</Label>
<Input
className="w-[20rem]"
value={templateNameInput}
onChange={(e) => setTemplateNameInput(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Label className="w-[6rem] whitespace-nowrap text-label">
템플릿내용
</Label>
<Input
className="w-[20rem]"
value={templateContentInput}
onChange={(e) => setTemplateContentInput(e.target.value)}
/>
</div>
</div>
<div className="flex w-full gap-6">
<div className="flex items-center gap-2">
<Label className="w-[6rem] whitespace-nowrap text-label">
채널
</Label>
<Select
value={senderKeyFilter}
onValueChange={setSenderKeyFilter}
>
<SelectTrigger className="w-[20rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>전체</SelectItem>
{senderKeyOptions?.map((k) => (
<SelectItem key={k.senderKey} value={k.senderKey}>
{k.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="w-[6rem] whitespace-nowrap text-label">
검수상태
</Label>
<Select
value={inspectionFilter}
onValueChange={setInspectionFilter}
>
<SelectTrigger className="w-[20rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>전체</SelectItem>
{inspectionOptions.map((k) => (
<SelectItem key={k} value={k}>
{k}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="w-[6rem] whitespace-nowrap text-label">
메시지유형
</Label>
<Select
value={messageTypeFilter}
onValueChange={setMessageTypeFilter}
>
<SelectTrigger className="w-[20rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>전체</SelectItem>
{messageTypeOptions.map((k) => (
<SelectItem key={k} value={k}>
{k}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex 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 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h3 className="text-2xl font-medium text-label">
알림톡 템플릿 관리
</h3>
<div className="flex flex-wrap gap-2">
<Button
type="button"
color="white"
onClick={handleRefreshTemplates}
disabled={syncTemplatesMutation.isPending}
>
템플릿갱신
</Button>
<Button
type="button"
color="green"
onClick={() => setRegistOpen(true)}
>
템플릿등록
</Button>
<Button
type="button"
color="gray"
onClick={handleInspectionRequest}
disabled={inspectionRequestMutation.isPending}
>
선택 검수요청
</Button>
<Button
type="button"
color="red"
onClick={handleDeleteSelectedTemplates}
disabled={deleteTemplatesMutation.isPending}
>
선택삭제
</Button>
<Button type="button" onClick={handleSaveTemplateGrid}>
저장
</Button>
</div>
</div>
<div className="card !m-[1.2rem] !p-0">{templateTableComponent}</div>
</div>
<div className="card card-border">
<div className="card-header flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h3 className="text-2xl font-medium text-label">변수 관리</h3>
<div className="flex flex-wrap gap-2">
<Button
type="button"
color="green"
className="h-[3.2rem]"
onClick={handleAddVarRow}
>
변수추가
</Button>
<Button
type="button"
color="red"
className="h-[3.2rem]"
onClick={handleDeleteSelectedVars}
disabled={deleteVarsMutation.isPending}
>
선택삭제
</Button>
<Button
type="button"
className="h-[3.2rem]"
onClick={handleSaveVars}
disabled={saveVarsMutation.isPending}
>
저장
</Button>
</div>
</div>
<div className="card !m-[1.2rem] !p-0">{varTableComponent}</div>
</div>
<NtftalkTempDetailDialog
templateCode={detailCode}
open={detailOpen}
onOpenChange={(o) => {
setDetailOpen(o);
if (!o) setDetailCode(null);
}}
/>
<NtftalkTempRegistDialog open={registOpen} onOpenChange={setRegistOpen} />
</div>
);
}
export { NtftalkTempPage };
@@ -0,0 +1,442 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
keepPreviousData,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
getCoreRowModel,
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,
Label,
LoadingWithAnimation,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Textarea,
} from "@common/components";
import {
type NtftalkTempSaveFormData,
ntftalkTempSaveFormSchema,
} from "../model/ntftalk-temp-save-schema";
import { createVarColumns } from "../model/var-table-columns";
interface NtftalkTempRegistDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const TEMPLATE_MESSAGE_TYPE_BASIC = "BA";
const SECURITY_TEMPLATE_EMPHASIZE_TYPE = {
enabled: "SECURITY",
disabled: "NONE",
} as const;
function NtftalkTempRegistDialog({
open,
onOpenChange,
}: NtftalkTempRegistDialogProps) {
const [isVarDialogOpen, setIsVarDialogOpen] = useState(false);
const [varPagination, setVarPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [varRowSelection, setVarRowSelection] = useState<RowSelectionState>({});
const [varSearchParams] = useState<NtftalkVarSearchParams>({
sort: "regDt,desc",
});
const queryClient = useQueryClient();
const alert = useAlertStore((s) => s.setMessage);
const senderQuery = useQuery({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK, "sender"],
queryFn: () => NtftalkBiztalkApi.getSender(),
});
const varQuery = useQuery({
queryKey: [
ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR,
varSearchParams,
varPagination,
],
queryFn: () =>
NtftalkVarApi.getNtftalkVarPage({
...varSearchParams,
page: varPagination.pageIndex,
size: varPagination.pageSize,
}),
placeholderData: keepPreviousData,
enabled: isVarDialogOpen,
});
const varColumns = useMemo(
() =>
createVarColumns({
onDraftFieldChange: () => {},
}),
[],
);
const { tableComponent, tableInstance } = useDataTable({
query: varQuery,
tableOptions: {
columns: varColumns,
state: { pagination: varPagination, rowSelection: varRowSelection },
manualPagination: true,
enableRowSelection: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setVarPagination,
onRowSelectionChange: setVarRowSelection,
getRowId: (row) => `srv:${row.varCd}`,
},
dataTableOptions: {
viewPagination: true,
},
});
const form = useForm<NtftalkTempSaveFormData>({
resolver: zodResolver(ntftalkTempSaveFormSchema),
defaultValues: {
templateCode: "",
senderKey: "",
senderKeyType: "S",
templateName: "",
templateContent: "",
templateMessageType: TEMPLATE_MESSAGE_TYPE_BASIC,
templateEmphasizeType: SECURITY_TEMPLATE_EMPHASIZE_TYPE.enabled,
templateImageUrl: "",
},
});
useEffect(() => {
if (!open) return;
form.reset();
}, [open]);
const { mutateAsync: saveTemp, isPending } = useMutationWrapper({
mutationFn: (data: NtftalkTempSaveFormData) =>
NtftalkTempApi.saveNtftalkTemp({
...data,
templateImageUrl: data.templateImageUrl || undefined,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP],
});
alert("등록되었습니다.");
form.reset();
onOpenChange(false);
},
});
const onSubmit = form.handleSubmit((data) =>
saveTemp({
...data,
senderKeyType: "S",
templateMessageType: TEMPLATE_MESSAGE_TYPE_BASIC,
}),
);
useEffect(() => {
if (!senderQuery.data) return;
if (form.getValues("senderKey")) return;
form.setValue("senderKey", senderQuery.data.senderKey);
}, [senderQuery.data, form]);
function handleInsertVariable() {
setIsVarDialogOpen(true);
}
function handleApplySelectedVariables() {
const rows = tableInstance.getSelectedRowModel().rows;
if (rows.length === 0) {
alert("추가할 변수를 선택해 주세요.");
return;
}
const varCodes = rows.map((row) => row.original.varCd?.trim() ?? "");
if (varCodes.some((varCd) => !varCd)) {
alert("선택 항목 중 변수코드가 없는 항목이 있습니다.");
return;
}
const currentValue = form.getValues("templateContent");
const tokens = varCodes.map((varCd) => `${varCd}`);
const appendValue = tokens.join("\n");
const nextValue = currentValue
? `${currentValue}\n${appendValue}`
: appendValue;
form.setValue("templateContent", nextValue, {
shouldValidate: true,
shouldDirty: true,
});
setVarRowSelection({});
setIsVarDialogOpen(false);
}
const varTableComponent = (() => {
if (varQuery.isLoading)
return (
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
);
if (varQuery.isError) return <NoData />;
return tableComponent;
})();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[85vh] max-w-[64rem] overflow-y-auto"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>알림 템플릿 등록</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id="ntftalk-temp-regist-form" onSubmit={onSubmit}>
<Table variant="secondary" className="w-full border">
<colgroup>
<col width="22%" />
<col width="78%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead className="px-3 py-2 text-left text-label">
메시지 유형
</TableHead>
<TableCell>기본형</TableCell>
</TableRow>
<TableRow>
<TableHead className="px-3 py-2 text-left text-label">
채널
</TableHead>
<TableCell>
<FormField
control={form.control}
name="senderKey"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="채널을 선택하세요." />
</SelectTrigger>
<SelectContent>
{senderQuery.data && (
<SelectItem
value={senderQuery.data.senderKey}
>
{senderQuery.data.uuid}
{senderQuery.data
? `(${senderQuery.data.name})`
: ""}
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage className="text-[1.2rem]" />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead className="px-3 py-2 text-left text-label">
템플릿코드
</TableHead>
<TableCell>
<FormField
control={form.control}
name="templateCode"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage className="text-[1.2rem]" />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead className="px-3 py-2 text-left text-label">
템플릿명
</TableHead>
<TableCell>
<FormField
control={form.control}
name="templateName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage className="text-[1.2rem]" />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead className="px-3 py-2 text-left text-label">
보안템플릿여부
</TableHead>
<TableCell>
<FormField
control={form.control}
name="templateEmphasizeType"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="flex items-center gap-8"
>
<div className="flex items-center gap-2">
<RadioGroupItem
value={
SECURITY_TEMPLATE_EMPHASIZE_TYPE.enabled
}
id="security-enabled"
/>
<Label htmlFor="security-enabled">설정</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
value={
SECURITY_TEMPLATE_EMPHASIZE_TYPE.disabled
}
id="security-disabled"
/>
<Label htmlFor="security-disabled">
미설정
</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage className="text-[1.2rem]" />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead className="px-3 py-2 text-left align-top text-label">
템플릿내용
</TableHead>
<TableCell>
<FormField
control={form.control}
name="templateContent"
render={({ field }) => (
<FormItem>
<div className="flex justify-end">
<Button
type="button"
color="white"
size="sm"
onClick={handleInsertVariable}
>
+ 변수추가
</Button>
</div>
<FormControl>
<Textarea
className="min-h-[20rem] resize-none !text-[1.3rem]"
{...field}
/>
</FormControl>
<FormMessage className="text-[1.2rem]" />
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</form>
</Form>
<DialogFooter>
<Button
type="submit"
form="ntftalk-temp-regist-form"
disabled={isPending}
>
저장
</Button>
</DialogFooter>
</DialogContent>
<Dialog open={isVarDialogOpen} onOpenChange={setIsVarDialogOpen}>
<DialogContent className="max-h-[80vh] max-w-[96rem] overflow-y-auto">
<DialogHeader>
<DialogTitle>변수 목록</DialogTitle>
</DialogHeader>
{varTableComponent}
<DialogFooter>
<Button
type="button"
color="white"
onClick={() => setIsVarDialogOpen(false)}
>
취소
</Button>
<Button type="button" onClick={handleApplySelectedVariables}>
추가
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Dialog>
);
}
export { NtftalkTempRegistDialog };
+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,
+6
View File
@@ -66,6 +66,12 @@ export const ENDPOINT = {
POST_EVALUATION: "/board-service/api/v1/pstEvl",
STATISTICS: "/board-service/api/v1/stat/bbs",
},
NTFTALK_SERVICE: {
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",
},
};
export const JWT_EXPIRATION_TIME = 1800000;
+16
View File
@@ -0,0 +1,16 @@
import type { AuthConfig } from "@common/auth";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
COOKIE_DOMAIN,
REFRESH_TOKEN,
} from "./env";
export const authConfig: AuthConfig = {
authUserId: AUTH_USER_ID || "token-id",
claimName: CLAIM_NAME || "Authorization",
cookieDomain: COOKIE_DOMAIN,
accessToken: ACCESS_TOKEN || "access-token",
refreshToken: REFRESH_TOKEN || "refresh-token",
};
+1
View File
@@ -11,3 +11,4 @@ export const ACCESS_TOKEN =
export const REFRESH_TOKEN =
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
@@ -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 };
+4 -4
View File
@@ -1,12 +1,13 @@
NEXT_PUBLIC_SITE_ID = DM
NEXT_PUBLIC_PORT = 3015
NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PORT = 3005
NEXT_PUBLIC_PROXY_HOST = http://localhost:3005
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_PROXY_HOST = http://localhost:3015
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
# NEXT_PUBLIC_FO_SERVER_API_URL = http://192.168.1.222:8883/operation-service/api/v1
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
@@ -19,7 +20,6 @@ NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&ver
NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
+10 -8
View File
@@ -1,23 +1,25 @@
NEXT_PUBLIC_SITE_ID = DM
NEXT_PUBLIC_PORT = 11035
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11035
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_PORT = 11315
NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11315
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
#NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
#NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
+4 -4
View File
@@ -8,16 +8,16 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
#NEXT_PUBLIC_PROXY_HOST = https://devreuse.hasuon.or.kr
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl
NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
NEXT_PUBLIC_GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
NEXT_PUBLIC_GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
-39
View File
@@ -1,39 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

Some files were not shown because too many files have changed in this diff Show More