diff --git a/README.md b/README.md index 06f7db2..cd6f2cb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ **학원/수업 운영을 위한 통합 플랫폼** - 수업 운영부터 학생 관리까지 SSam B에서 한번에 운영하세요 ![Ssam B Landing](assets/ssamb_landing.png) @@ -34,6 +33,7 @@ - [🚢 배포 및 운영](#-배포-및-운영) --- + ## ❤️ 프로젝트 개요 ~~ @@ -91,6 +91,7 @@ ## 🛠 기술 스택 ### 🏗 Core +

@@ -99,6 +100,7 @@

### 🎨 UI / UX +

@@ -109,6 +111,7 @@

### ⚙ State & Data +

@@ -118,6 +121,7 @@

### 🩺 Quality +

@@ -126,6 +130,7 @@

--- + ## 🌐 시스템 아키텍처 ~~ @@ -134,15 +139,15 @@ ## 🛸 팀 소개 - ## 👥 팀원 소개 -| 👑 박창기 | 이유리 | 임경민 | 김윤기 | -| :---: | :---: | :---: | :---: | -| ![창기](https://github.com/p-changki.png?s=20) | ![유리](https://github.com/yoorrll.png?s=20)| ![경민](https://github.com/play-ancora-gyungmin.png?s=20) | ![윤기](https://github.com/rklpoi5678.png?s=20) | -| PM & 프론트 | 프론트 | 백엔드 | 백엔드 & 배포 | +| 👑 박창기 | 이유리 | 임경민 | 김윤기 | +| :--------------------------------------------: | :------------------------------------------: | :-------------------------------------------------------: | :---------------------------------------------: | +| ![창기](https://github.com/p-changki.png?s=20) | ![유리](https://github.com/yoorrll.png?s=20) | ![경민](https://github.com/play-ancora-gyungmin.png?s=20) | ![윤기](https://github.com/rklpoi5678.png?s=20) | +| PM & 프론트 | 프론트 | 백엔드 | 백엔드 & 배포 | --- + ## 🥊 트러블 슈팅 ~~ diff --git a/src/app/(dashboard)/educators/exams/clinic/_components/ClinicHeader.tsx b/src/app/(dashboard)/educators/exams/clinic/_components/ClinicHeader.tsx index da32884..bd10fcc 100644 --- a/src/app/(dashboard)/educators/exams/clinic/_components/ClinicHeader.tsx +++ b/src/app/(dashboard)/educators/exams/clinic/_components/ClinicHeader.tsx @@ -9,6 +9,7 @@ import { KakaoNotificationModal, NotificationRecipient, } from "@/components/common/modals/KakaoNotificationModal"; +import { sendKakaoMemo } from "@/services/kakao.service"; import type { ClinicStudent } from "@/types/clinics"; type ClinicHeaderProps = { @@ -18,6 +19,8 @@ type ClinicHeaderProps = { isSending?: boolean; }; +type NotificationTargetType = "all" | "student" | "parent"; + export function ClinicHeader({ students, selectedIds, @@ -53,6 +56,45 @@ export function ClinicHeader({ } }; + const handleSend = async ( + recipients: NotificationRecipient[], + message: string, + targetType: NotificationTargetType + ) => { + const targetLabel = + targetType === "all" + ? "학생+학부모" + : targetType === "student" + ? "학생" + : "학부모"; + const deliverableRecipients = recipients.filter((recipient) => { + if (targetType === "student") return Boolean(recipient.phone); + if (targetType === "parent") return Boolean(recipient.parentPhone); + return Boolean(recipient.phone || recipient.parentPhone); + }); + + if (deliverableRecipients.length === 0) { + throw new Error("No available recipients for selected target type."); + } + + const nameList = deliverableRecipients.map((r) => r.name).join(", "); + + try { + await sendKakaoMemo({ + title: `[클리닉 알림] ${deliverableRecipients.length}명 대상 (${targetLabel})`, + description: `발송 대상: ${targetLabel}\n${message}\n\n수신 대상: ${nameList}`, + webUrl: window.location.origin, + buttonTitle: "홈페이지로 가기", + }); + + // 기존 완료 처리 로직 실행 + onSendNotification(); + } catch (error) { + console.error("Clinic notification send failed:", error); + throw error; + } + }; + return ( <>
@@ -97,7 +139,9 @@ export function ClinicHeader({ title="클리닉 알림 발송 준비" subtitle="클리닉 대상자 발송 정보 확인" defaultMessage={clinicDefaultMessage} - sendAction={() => onSendNotification()} + sendAction={(recipients, message, targetType) => + handleSend(recipients, message, targetType) + } /> ); diff --git a/src/app/(dashboard)/educators/exams/report/_hooks/usePremiumReportTemplateActions.tsx b/src/app/(dashboard)/educators/exams/report/_hooks/usePremiumReportTemplateActions.tsx index d7fe5f6..7317f81 100644 --- a/src/app/(dashboard)/educators/exams/report/_hooks/usePremiumReportTemplateActions.tsx +++ b/src/app/(dashboard)/educators/exams/report/_hooks/usePremiumReportTemplateActions.tsx @@ -10,6 +10,7 @@ import { saveStudentReport, uploadGradeReportFile, } from "@/services/exams/report.service"; +import { sendKakaoMemo } from "@/services/kakao.service"; import type { ReportTemplateExamData } from "@/types/report"; import { createReportPreviewImageFile } from "@/utils/report-preview-image"; import { @@ -226,11 +227,16 @@ export const usePremiumReportTemplateActions = ({ throw new Error("성적표 다운로드 URL을 가져오지 못했습니다."); } + await sendKakaoMemo({ + title: `${examData.studentName} | ${examData.examName} 성적표`, + description: `점수: ${examData.score}점 · 석차: ${examData.rank}/${examData.totalStudents}`, + imageUrl: imageUploadResult.reportUrl ?? undefined, + webUrl: downloadUrl, + }); + await showAlert({ - title: "발송 준비 완료", - description: imageUploadResult.reportUrl - ? "PDF와 미리보기 이미지 업로드가 완료되었습니다. 카카오톡 발송 기능은 현재 연동 준비 중입니다." - : "성적표 파일 업로드가 완료되었습니다. 카카오톡 발송 기능은 현재 연동 준비 중입니다.", + title: "발송 완료", + description: "카카오톡으로 성적표가 전송되었습니다.", }); } catch (error) { console.error("Report send failed:", error); diff --git a/src/app/(dashboard)/educators/exams/report/_hooks/useSimpleReportTemplateActions.tsx b/src/app/(dashboard)/educators/exams/report/_hooks/useSimpleReportTemplateActions.tsx index 6d7f860..47e6b2b 100644 --- a/src/app/(dashboard)/educators/exams/report/_hooks/useSimpleReportTemplateActions.tsx +++ b/src/app/(dashboard)/educators/exams/report/_hooks/useSimpleReportTemplateActions.tsx @@ -10,6 +10,7 @@ import { saveStudentReport, uploadGradeReportFile, } from "@/services/exams/report.service"; +import { sendKakaoMemo } from "@/services/kakao.service"; import type { ReportTemplateExamData } from "@/types/report"; import { createReportPreviewImageFile } from "@/utils/report-preview-image"; @@ -174,11 +175,16 @@ export const useSimpleReportTemplateActions = ({ throw new Error("성적표 다운로드 URL을 가져오지 못했습니다."); } + await sendKakaoMemo({ + title: `${examData.studentName} | ${examData.examName} 성적표`, + description: `점수: ${examData.score}점 · 석차: ${examData.rank}/${examData.totalStudents}`, + imageUrl: imageUploadResult.reportUrl ?? undefined, + webUrl: downloadUrl, + }); + await showAlert({ - title: "발송 준비 완료", - description: imageUploadResult.reportUrl - ? "PDF와 미리보기 이미지 업로드가 완료되었습니다. 카카오톡 발송 기능은 현재 연동 준비 중입니다." - : "성적표 파일 업로드가 완료되었습니다. 카카오톡 발송 기능은 현재 연동 준비 중입니다.", + title: "발송 완료", + description: "카카오톡으로 성적표가 전송되었습니다.", }); } catch (error) { console.error("Report send failed:", error); diff --git a/src/app/(dashboard)/educators/students/_components/modal/TalkNotificationModal.tsx b/src/app/(dashboard)/educators/students/_components/modal/TalkNotificationModal.tsx index 68dd35a..703f6ad 100644 --- a/src/app/(dashboard)/educators/students/_components/modal/TalkNotificationModal.tsx +++ b/src/app/(dashboard)/educators/students/_components/modal/TalkNotificationModal.tsx @@ -19,39 +19,79 @@ import SelectBtn from "@/components/common/button/SelectBtn"; import { useModal } from "@/providers/ModalProvider"; import { useStudentSelectionStore } from "@/stores/studentsList.store"; import { StudentProfileAvatar } from "@/components/common/avatar/StudentProfileAvatar"; +import { sendKakaoMemo } from "@/services/kakao.service"; +import { KAKAO_MESSAGE_LIMITS } from "@/constants/kakao"; +import { useDialogAlert } from "@/hooks/useDialogAlert"; type SendTarget = "all" | "student" | "parent"; export function TalkNotificationModal() { const { isOpen, closeModal } = useModal(); + const { showAlert } = useDialogAlert(); const [sendChannel, setSendChannel] = useState("kakao"); const [sendTarget, setSendTarget] = useState("all"); const [messageContent, setMessageContent] = useState(""); + const [isSending, setIsSending] = useState(false); - const { - selectedStudents, - selectedStudentIds, - removeStudent, - resetSelection, - } = useStudentSelectionStore(); - - const getExpectedRecipients = () => { - const studentCount = selectedStudents.length; - if (sendTarget === "all") return studentCount * 2; // 학생 + 학부모 - return studentCount; - }; + const { selectedStudents, removeStudent, resetSelection } = + useStudentSelectionStore(); - const handleSubmit = () => { - console.log({ - studentIds: selectedStudentIds, - sendChannel, - sendTarget, - messageContent, + const getRecipientStats = () => { + const studentCount = selectedStudents.filter((s) => s.phoneNumber).length; + const parentCount = selectedStudents.filter((s) => s.parentPhone).length; + const expectedRecipients = + sendTarget === "all" + ? studentCount + parentCount + : sendTarget === "student" + ? studentCount + : parentCount; + const deliverableStudents = selectedStudents.filter((student) => { + if (sendTarget === "student") return Boolean(student.phoneNumber); + if (sendTarget === "parent") return Boolean(student.parentPhone); + return Boolean(student.phoneNumber || student.parentPhone); }); - resetForm(); - resetSelection(); - closeModal(); + return { expectedRecipients, deliverableStudents }; + }; + + const handleSubmit = async () => { + const { expectedRecipients, deliverableStudents } = getRecipientStats(); + const nameList = deliverableStudents.map((s) => s.name).join(", "); + const targetLabel = + sendTarget === "all" + ? "학생+학부모" + : sendTarget === "student" + ? "학생" + : "학부모"; + + if (expectedRecipients === 0) { + await showAlert({ + title: "전송 불가", + description: "선택한 발송 대상의 연락처가 없습니다.", + }); + return; + } + + setIsSending(true); + try { + await sendKakaoMemo({ + title: `[알림] ${expectedRecipients}명 대상 (${targetLabel})`, + description: `${messageContent}\n\n수신 대상: ${nameList}`, + webUrl: window.location.origin, + buttonTitle: "홈페이지로 가기", + }); + resetForm(); + resetSelection(); + closeModal(); + } catch (error) { + console.error("Talk notification send failed:", error); + await showAlert({ + title: "전송 실패", + description: "카카오 알림 전송 중 오류가 발생했습니다.", + }); + } finally { + setIsSending(false); + } }; const resetForm = () => { @@ -89,7 +129,7 @@ export function TalkNotificationModal() { 발송 설정

- 총 예상 수신: {getExpectedRecipients()}명 + 총 예상 수신: {getRecipientStats().expectedRecipients}명

@@ -189,15 +229,27 @@ export function TalkNotificationModal() {
-

- 메시지 내용 -

+
+

+ 메시지 내용 +

+ KAKAO_MESSAGE_LIMITS.DESCRIPTION + ? "text-red-500" + : "text-muted-foreground" + }`} + > + {messageContent.length} / {KAKAO_MESSAGE_LIMITS.DESCRIPTION} + +