Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

**학원/수업 운영을 위한 통합 플랫폼**


수업 운영부터 학생 관리까지 SSam B에서 한번에 운영하세요

![Ssam B Landing](assets/ssamb_landing.png)
Expand Down Expand Up @@ -34,6 +33,7 @@
- [🚢 배포 및 운영](#-배포-및-운영)

---

## ❤️ 프로젝트 개요

~~
Expand Down Expand Up @@ -91,6 +91,7 @@
## 🛠 기술 스택

### 🏗 Core

<p>
<img src="https://img.shields.io/badge/Next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white"/>
<img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white"/>
Expand All @@ -99,6 +100,7 @@
</p>

### 🎨 UI / UX

<p>
<img src="https://img.shields.io/badge/React-61DAFB?style=for-the-badge&logo=react&logoColor=black"/>
<img src="https://img.shields.io/badge/Tailwind_CSS-06B6D4?style=for-the-badge&logo=tailwindcss&logoColor=white"/>
Expand All @@ -109,6 +111,7 @@
</p>

### ⚙ State & Data

<p>
<img src="https://img.shields.io/badge/TanStack_Query-FF4154?style=for-the-badge"/>
<img src="https://img.shields.io/badge/Zustand-443E38?style=for-the-badge"/>
Expand All @@ -118,6 +121,7 @@
</p>

### 🩺 Quality

<p>
<img src="https://img.shields.io/badge/Sentry-362D59?style=for-the-badge&logo=sentry&logoColor=white"/>
<img src="https://img.shields.io/badge/Jest-C21325?style=for-the-badge&logo=jest&logoColor=white"/>
Expand All @@ -126,6 +130,7 @@
</p>

---

## 🌐 시스템 아키텍처

~~
Expand All @@ -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 & 프론트 | 프론트 | 백엔드 | 백엔드 & 배포 |

---

## 🥊 트러블 슈팅

~~
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -18,6 +19,8 @@ type ClinicHeaderProps = {
isSending?: boolean;
};

type NotificationTargetType = "all" | "student" | "parent";

export function ClinicHeader({
students,
selectedIds,
Expand Down Expand Up @@ -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 (
<>
<section className="-mx-6 -mt-6 border-b border-[#e9ebf0] bg-white px-6 py-6 sm:px-8 sm:py-7">
Expand Down Expand Up @@ -97,7 +139,9 @@ export function ClinicHeader({
title="클리닉 알림 발송 준비"
subtitle="클리닉 대상자 발송 정보 확인"
defaultMessage={clinicDefaultMessage}
sendAction={() => onSendNotification()}
sendAction={(recipients, message, targetType) =>
handleSend(recipients, message, targetType)
}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SendTarget>("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 = () => {
Expand Down Expand Up @@ -89,7 +129,7 @@ export function TalkNotificationModal() {
발송 설정
</h3>
<p className="text-xs font-medium text-blue-600 bg-blue-50 px-3 py-1 rounded-full">
총 예상 수신: {getExpectedRecipients()}명
총 예상 수신: {getRecipientStats().expectedRecipients}명
</p>
</div>
<div className="w-full grid grid-cols-2 gap-4 items-start pb-2">
Expand Down Expand Up @@ -189,15 +229,27 @@ export function TalkNotificationModal() {
</div>

<div className="space-y-4 border rounded-[20px] px-[24px] py-[16px] bg-surface-normal-light-alternative">
<h3 className="text-[18px] font-semibold text-label-neutral py-[11px]">
메시지 내용
</h3>
<div className="flex items-center justify-between">
<h3 className="text-[18px] font-semibold text-label-neutral py-[11px]">
메시지 내용
</h3>
<span
className={`text-[12px] font-medium tabular-nums ${
messageContent.length > KAKAO_MESSAGE_LIMITS.DESCRIPTION
? "text-red-500"
: "text-muted-foreground"
}`}
>
{messageContent.length} / {KAKAO_MESSAGE_LIMITS.DESCRIPTION}
</span>
</div>
<div className="space-y-2">
<Textarea
value={messageContent}
onChange={(e) => setMessageContent(e.target.value)}
placeholder="전송할 메시지를 입력하세요"
className="text-base p-4 min-h-[160px] w-full rounded-[12px] bg-white border border-neutral-200 shadow-none focus-visible:ring-blue-500"
maxLength={KAKAO_MESSAGE_LIMITS.DESCRIPTION}
rows={6}
/>
</div>
Expand All @@ -224,10 +276,11 @@ export function TalkNotificationModal() {
disabled={
!messageContent ||
selectedStudents.length === 0 ||
getExpectedRecipients() === 0
getRecipientStats().expectedRecipients === 0 ||
isSending
}
>
알림 전송
{isSending ? "전송 중..." : "알림 전송"}
</Button>
</div>
</DialogFooter>
Expand Down
19 changes: 16 additions & 3 deletions src/components/common/modals/KakaoNotificationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import SelectBtn from "@/components/common/button/SelectBtn";
import { StudentProfileAvatar } from "@/components/common/avatar/StudentProfileAvatar";
import { useDialogAlert } from "@/hooks/useDialogAlert";
import { KAKAO_MESSAGE_LIMITS } from "@/constants/kakao";

// 공통 수신자 타입
export type NotificationRecipient = {
Expand Down Expand Up @@ -439,12 +440,24 @@ export function KakaoNotificationModal({

{/* 메시지 내용 */}
<div className="space-y-3">
<h3 className="text-[14px] font-semibold text-[#6b6f80]">
메시지 내용
</h3>
<div className="flex items-center justify-between">
<h3 className="text-[14px] font-semibold text-[#6b6f80]">
메시지 내용
</h3>
<span
className={`text-[12px] font-medium tabular-nums ${
message.length > KAKAO_MESSAGE_LIMITS.DESCRIPTION
? "text-[#e55b5b]"
: "text-[#8b90a3]"
}`}
>
{message.length} / {KAKAO_MESSAGE_LIMITS.DESCRIPTION}
</span>
</div>
<Textarea
placeholder="전송할 메시지를 입력하세요"
className="min-h-[120px] resize-none rounded-[12px] border-[#d6d9e0] bg-[#fcfcfd] p-4 text-[14px] leading-6 text-[#4a4d5c] placeholder:text-[#8b90a3] focus:border-[#3863f6] focus:ring-[#3863f6]/20"
maxLength={KAKAO_MESSAGE_LIMITS.DESCRIPTION}
value={message}
onChange={(event) => setMessage(event.target.value)}
/>
Expand Down
Loading