diff --git a/apps/web/src/app/(main)/apply-management/_components/SideTabs/SmsSideTab/SmsSideTab.tsx b/apps/web/src/app/(main)/apply-management/_components/SideTabs/SmsSideTab/SmsSideTab.tsx
index 1f542e02..d9af7c2e 100644
--- a/apps/web/src/app/(main)/apply-management/_components/SideTabs/SmsSideTab/SmsSideTab.tsx
+++ b/apps/web/src/app/(main)/apply-management/_components/SideTabs/SmsSideTab/SmsSideTab.tsx
@@ -1,41 +1,77 @@
'use client';
+
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import React, { useEffect, useMemo, useRef, useState } from 'react';
+
import { IcSendBtn } from '@repo/ui/icons/mono';
import { IcHeaderSms, IcFilePlus, IcTagDelete } from '@repo/ui/icons/colored';
+import { IcFileBtn } from '@repo/ui/icons/mono';
+
import * as styles from '../MailSideTab/MailSideTab.css';
import { SideTab } from '../SideTab/SideTab';
import {
TemplatesAccordion,
Template,
} from '../TemplatesAccordion/TemplatesAccordion';
+
import { Button } from '@repo/ui/Button';
import { Text } from '@repo/ui/Text';
import { Flex } from '@repo/ui/Flex';
-import { IcFileBtn } from '@repo/ui/icons/mono';
+
import { useBulkSms } from '@web/store/mutation/useBulkSms';
import { useCreateTemplate } from '@web/store/mutation/useCreateTemplate';
+import { useUpdateTemplate } from '@web/store/mutation/useUpdateTemplate';
+import { useDeleteTemplate } from '@web/store/mutation/useDeleteTemplate';
+
import {
useTemplatesQuery,
TemplateDetail,
} from '@web/store/query/useTemplatesQuery';
import { useTemplateDetailQuery } from '@web/store/query/useTemplateDetailQuery';
-import { createEditor, Descendant } from 'slate';
+import { createEditor, Descendant, Transforms } from 'slate';
import { withHistory } from 'slate-history';
import { withReact } from 'slate-react';
+
import {
RichTextEditor,
insertVariable,
serialize,
withVariables,
} from '../RichTextEditor/RichTextEditor';
+
import { deserializeHtml } from '@web/utils/deserializeHtml';
import { getClientSideTokens } from '@web/utils/getClientSideTokens';
import { useModal } from '@repo/ui/hooks';
import { useRecipientsStore } from '@web/store/state/useRecipientsStore';
-import { useUpdateTemplate } from '@web/store/mutation/useUpdateTemplate';
-import { useDeleteTemplate } from '@web/store/mutation/useDeleteTemplate';
+
+function htmlToPlainText(html: string) {
+ if (!html) return '';
+
+ //
/
→ \n
+ let text = html.replace(/
/gi, '\n');
+
+ //
, , 등 블록 끝 → \n
+ text = text.replace(/<\/(p|div|li|h[1-6])>/gi, '\n');
+
+ // 모든 태그 제거
+ text = text.replace(/<[^>]+>/g, '');
+
+ // HTML 엔티티 일부 처리 (필요한 만큼만)
+ text = text
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'");
+
+ // 공백/줄바꿈 정리
+ text = text.replace(/\n{3,}/g, '\n\n').trim();
+
+ return text;
+}
+
function ensureParagraph(html: string) {
const trimmed = html.trim();
@@ -55,17 +91,21 @@ interface SmsSideTabProps {
onClose: () => void;
}
-export function SmsSideTab({ applicationIds, recipients, onClose }: SmsSideTabProps) {
- const { organizationId } = getClientSideTokens();
- const { confirm } = useModal();
-
+export function SmsSideTab({
+ applicationIds,
+ recipients,
+ onClose,
+}: SmsSideTabProps) {
const router = useRouter();
const params = useParams();
const search = useSearchParams();
const tab = Array.isArray(params.tab) ? params.tab[0] : (params.tab as string);
const recruitmentId = search.get('recruitmentId');
- const sideTab = search.get('sideTab') ?? 'sms';
+ const sideTab = search.get('sideTab') ?? 'sms';
+
+ const { organizationId } = getClientSideTokens();
+ const { confirm } = useModal();
const openInviteModal = () => {
if (!recruitmentId) return;
@@ -81,27 +121,28 @@ export function SmsSideTab({ applicationIds, recipients, onClose }: SmsSideTabPr
clearRecipients,
} = useRecipientsStore();
+ // recipients seed 병합
useEffect(() => {
const seeds = (recipients ?? [])
.filter(Boolean)
.map((name, idx) => ({
id: `seed-sms-${idx}-${name}`,
name,
- email: '',
+ email: '',
}));
mergeRecipients(seeds, 'name');
}, [recipients, mergeRecipients]);
-
-
const handleClose = () => {
clearRecipients();
onClose();
};
+ // 템플릿 목록
const { data: tplSummaries = [] } = useTemplatesQuery('SMS');
const [templates, setTemplates] = useState([]);
+
useEffect(() => {
setTemplates(
tplSummaries.map((t) => ({
@@ -113,39 +154,39 @@ export function SmsSideTab({ applicationIds, recipients, onClose }: SmsSideTabPr
}, [tplSummaries]);
const fileInputRef = useRef(null);
-
- const [selectedTemplateId, setSelectedTemplateId] = useState(null);
+
+ const [selectedTemplateId, setSelectedTemplateId] = useState(
+ null
+ );
const [isCreating, setIsCreating] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [attachment, setAttachment] = useState();
- const editor = useMemo(() => withHistory(withReact(withVariables(createEditor()))), []);
- const [editorValue, setEditorValue] = useState([
- { type: 'paragraph', children: [{ text: '' }] },
- ]);
-
- const tplDetailQ = useTemplateDetailQuery(selectedTemplateId ? Number(selectedTemplateId) : -1);
- useEffect(() => {
- if (!selectedTemplateId) return;
- if (!tplDetailQ.data) return;
- if (isCreating) return;
-
- const nodes = deserializeHtml(tplDetailQ.data.body);
- setEditorValue(nodes);
- }, [selectedTemplateId, tplDetailQ.data, isCreating]);
+ // ✅ MailSideTab처럼 editor children을 갈아끼우려면 editor 인스턴스가 필요
+ const editor = useMemo(
+ () => withHistory(withReact(withVariables(createEditor()))),
+ []
+ );
- const sendSms = useBulkSms();
- const createTpl = useCreateTemplate();
- const updateTpl = useUpdateTemplate();
- const deleteTpl = useDeleteTemplate();
-
const EMPTY_NODES: Descendant[] = [
{ type: 'paragraph', children: [{ text: '' }] },
];
+ const [editorValue, setEditorValue] = useState(EMPTY_NODES);
+
+ // ✅ 핵심: Slate editor 내부 children까지 교체해야 화면에 안정적으로 반영됨
const resetEditorTo = (nodes: Descendant[]) => {
+ Transforms.deselect(editor);
+
+ for (let i = editor.children.length - 1; i >= 0; i--) {
+ Transforms.removeNodes(editor, { at: [i] });
+ }
+
+ Transforms.insertNodes(editor, nodes);
+
+ Transforms.deselect(editor);
setEditorValue(nodes);
};
@@ -156,24 +197,57 @@ export function SmsSideTab({ applicationIds, recipients, onClose }: SmsSideTabPr
resetEditorTo(EMPTY_NODES);
};
+ // 선택된 템플릿 상세
+ const tplDetailQ = useTemplateDetailQuery(
+ selectedTemplateId ? Number(selectedTemplateId) : -1
+ );
+
+ // ✅ 템플릿 선택 시 본문을 에디터에 반영 (MailSideTab과 동일한 방식)
+ useEffect(() => {
+ if (!selectedTemplateId) return;
+ if (!tplDetailQ.data) return;
+ if (isCreating) return;
+
+ // 템플릿 이름을 제목 input에 채우고 싶으면(선택/수정 UX 안정)
+ setNewTitle(tplDetailQ.data.name ?? '');
+
+ const nodes = deserializeHtml(tplDetailQ.data.body);
+ resetEditorTo(nodes);
+ }, [selectedTemplateId, tplDetailQ.data, isCreating, editor]);
+
+ const sendSms = useBulkSms();
+ const createTpl = useCreateTemplate();
+ const updateTpl = useUpdateTemplate();
+ const deleteTpl = useDeleteTemplate();
+
+ // 새 템플릿 생성
const handleCreate = () => {
setIsCreating(true);
setIsEditing(false);
resetCreateDraft();
};
+ // 템플릿 선택 (보기 모드)
const handleSelect = (tpl: Template) => {
setIsCreating(false);
setIsEditing(false);
setSelectedTemplateId(tpl.id);
+
+ // ✅ 제목 input이 비어있어서 업데이트 name이 꼬이는 케이스 방지
+ setNewTitle(tpl.title);
};
+ // 수정 모드 진입
const handleEdit = (tpl: Template) => {
setSelectedTemplateId(tpl.id);
setIsCreating(false);
setIsEditing(true);
+
+ // ✅ 수정 모드에서 title이 빈 상태로 저장되는 것 방지
+ setNewTitle(tpl.title);
};
+ // 삭제
const handleDelete = (tpl: Template) => {
confirm({
type: 'warning',
@@ -189,10 +263,10 @@ export function SmsSideTab({ applicationIds, recipients, onClose }: SmsSideTabPr
{
onSuccess: () => {
setTemplates((prev) => prev.filter((t) => t.id !== tpl.id));
-
+
if (selectedTemplateId === tpl.id) {
setSelectedTemplateId(null);
- setEditorValue([{ type: 'paragraph', children: [{ text: '' }] }]);
+ resetEditorTo(EMPTY_NODES);
}
},
}
@@ -219,19 +293,28 @@ export function SmsSideTab({ applicationIds, recipients, onClose }: SmsSideTabPr
]);
setSelectedTemplateId(String(newTpl.id));
setIsCreating(false);
+ setIsEditing(false);
+
+ // 생성 직후 상태도 안정적으로
+ setNewTitle(newTpl.name ?? newTitle);
},
}
);
};
const handleUpdateTemplate = () => {
- const body = ensureParagraph(serialize(editorValue));
if (!selectedTemplateId) return;
-
+
+ const body = ensureParagraph(serialize(editorValue));
+ const fallbackName =
+ tplDetailQ.data?.name ??
+ tplSummaries.find((t) => String(t.id) === selectedTemplateId)?.name ??
+ '';
+
updateTpl.mutate(
{
templateId: Number(selectedTemplateId),
- name: newTitle.trim() || tplSummaries.find((t) => String(t.id) === selectedTemplateId)?.name || '',
+ name: newTitle.trim() || fallbackName,
body,
medium: 'SMS',
subject: undefined,
@@ -239,7 +322,8 @@ export function SmsSideTab({ applicationIds, recipients, onClose }: SmsSideTabPr
{
onSuccess: () => {
setIsEditing(false);
-
+
+ // 로컬 리스트도 바로 반영
setTemplates((prev) =>
prev.map((t) =>
t.id === selectedTemplateId
@@ -254,8 +338,8 @@ export function SmsSideTab({ applicationIds, recipients, onClose }: SmsSideTabPr
const handleSend = () => {
const rawHtml = serialize(editorValue);
- const message = ensureParagraph(rawHtml);
-
+ const message = htmlToPlainText(rawHtml);
+
sendSms.mutate(
{
applicationIds,
@@ -301,24 +385,28 @@ export function SmsSideTab({ applicationIds, recipients, onClose }: SmsSideTabPr
onCreate={handleCreate}
onEdit={handleEdit}
onDelete={handleDelete}
- onCancelCreate={handleCancelCreate}
+ onCancelCreate={handleCancelCreate}
/>
-
+ {/* 받는 사람 */}
{!isCreating && !isEditing && (
-
+
받는 사람
{storeRecipients.length === 0 ? (
-
+
) : (
{storeRecipients.map((u) => (
@@ -339,39 +427,59 @@ export function SmsSideTab({ applicationIds, recipients, onClose }: SmsSideTabPr
)}
-
-
- 파일 첨부
-
-
-
-
+ {/* 파일 첨부 */}
+
+
+ 파일 첨부
+
+
+
+
-
-
- 변수 설정
-
-
- {(['name', 'position', 'interviewRoom', 'interviewDateTime'] as const).map((v) => (
+ {/* 변수 설정 */}
+
+
+ 변수 설정
+
+
+
+ {(['name', 'position', 'interviewRoom', 'interviewDateTime'] as const).map(
+ (v) => (
- ))}
-
-
+ )
+ )}
+
+
+ {/* 에디터 */}