Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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` 레이어
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
@@ -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}</>;
|
||||
|
||||
@@ -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();
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -125,7 +125,7 @@ function AdminPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -48,7 +48,7 @@ function BannerPage() {
|
||||
}),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -46,7 +46,7 @@ function ImagePage() {
|
||||
}),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -64,7 +64,7 @@ function RolePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
|
||||
@@ -35,7 +35,7 @@ function SitePage() {
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -102,7 +102,7 @@ function UsersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user