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) => ( - ))} - -
+ ) + )} +
+
+ {/* 에디터 */}