platform/lms-adm-fe release promotion snapshot

This commit is contained in:
2026-07-07 09:15:47 +00:00
parent 1bf81f86bb
commit d029bdeda7
59 changed files with 12096 additions and 2 deletions
+468
View File
@@ -0,0 +1,468 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, ChevronRight, Loader2, MessageCircle, Send, UserRound, X } from 'lucide-react';
import { io } from 'socket.io-client';
import type { Socket } from 'socket.io-client';
import {
CHAT_SOCKET_BASE_URL,
createInstructorChatMessage,
getInstructorChatThread,
listInstructorChatThreads,
type InstructorChatMessage,
type InstructorChatThread,
} from '../lib/chat';
import type { AuthSession } from '../types/auth';
type InstructorChatPanelProps = {
session: AuthSession;
open: boolean;
initialThreadId?: string | null;
initialThreadRequestId?: number;
onClose: () => void;
};
type ChatNotificationPayload = {
type: 'chat.message.created';
threadId: string;
messageId: string;
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM';
preview: string;
createdAt: string;
};
export function InstructorChatPanel({ session, open, initialThreadId = null, initialThreadRequestId = 0, onClose }: InstructorChatPanelProps) {
const [threads, setThreads] = useState<InstructorChatThread[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [roomLoading, setRoomLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mounted, setMounted] = useState(open);
const selectedThread = useMemo(() => threads.find((thread) => thread.id === selectedThreadId) ?? null, [selectedThreadId, threads]);
const refreshThreadList = (showLoading = false) => {
let cancelled = false;
if (showLoading) setLoading(true);
setError(null);
const request = listInstructorChatThreads(session.accessToken)
.then((items) => {
if (!cancelled) setThreads(items);
})
.catch((reason: Error) => {
if (!cancelled) {
setThreads([]);
setError(reason.message);
}
})
.finally(() => {
if (!cancelled && showLoading) setLoading(false);
});
return {
cancel: () => {
cancelled = true;
},
request,
};
};
const refreshSelectedThread = (threadId: string, showLoading = false) => {
if (showLoading) setRoomLoading(true);
setError(null);
return getInstructorChatThread(session.accessToken, threadId)
.then((thread) => {
setThreads((current) => replaceThread(current, thread));
})
.catch((reason: Error) => {
setError(reason.message);
})
.finally(() => {
if (showLoading) setRoomLoading(false);
});
};
useEffect(() => {
if (!open) return;
const refresh = refreshThreadList(true);
return () => {
refresh.cancel();
};
}, [open, session.accessToken]);
useEffect(() => {
if (!open || !initialThreadId) return;
setSelectedThreadId(initialThreadId);
void refreshSelectedThread(initialThreadId, true);
}, [open, initialThreadId, initialThreadRequestId, session.accessToken]);
useEffect(() => {
if (!open) return;
const socket: Socket = io(CHAT_SOCKET_BASE_URL, {
auth: { token: session.accessToken },
transports: ['websocket', 'polling'],
});
const handleMessageCreated = (payload: ChatNotificationPayload) => {
void refreshThreadList(false).request;
if (selectedThreadId && payload.threadId === selectedThreadId) {
void refreshSelectedThread(selectedThreadId, false);
}
};
socket.on('connect', () => {
void refreshThreadList(false).request;
if (selectedThreadId) void refreshSelectedThread(selectedThreadId, false);
});
socket.on('chat.message.created', handleMessageCreated);
return () => {
socket.off('chat.message.created', handleMessageCreated);
socket.disconnect();
};
}, [open, selectedThreadId, session.accessToken]);
useEffect(() => {
if (!open) setSelectedThreadId(null);
}, [open]);
useEffect(() => {
if (open) {
setMounted(true);
return;
}
const timer = window.setTimeout(() => setMounted(false), 220);
return () => window.clearTimeout(timer);
}, [open]);
const handleOpenThread = (threadId: string) => {
setSelectedThreadId(threadId);
void refreshSelectedThread(threadId, true);
};
const handleSendMessage = async (threadId: string, content: string) => {
const updatedThread = await createInstructorChatMessage(session.accessToken, threadId, content);
setThreads((current) => replaceThread(current, updatedThread));
};
if (!mounted) return null;
return (
<aside className={`fixed bottom-0 right-0 top-[60px] z-30 flex w-full max-w-[420px] flex-col border-l border-[#cbd5e1] bg-white/92 shadow-[0_22px_70px_rgba(15,23,42,0.18)] backdrop-blur-md transition-transform duration-200 ease-out ${open ? 'translate-x-0' : 'translate-x-full'}`}>
{selectedThread ? (
<ChatRoom thread={selectedThread} loading={roomLoading} onBack={() => setSelectedThreadId(null)} onClose={onClose} onSend={handleSendMessage} />
) : (
<>
<div className="flex h-[68px] items-center justify-between border-b border-[#cbd5e1] px-5">
<div>
<p className="text-[15px] font-black text-[#0f172a]">채팅</p>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">수강생 문의 목록</p>
</div>
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="채팅 닫기" title="채팅 닫기" onClick={onClose}>
<X size={17} aria-hidden="true" />
</button>
</div>
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4">
{loading ? (
<div className="flex h-full items-center justify-center text-[#64748b]">
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
<span className="text-[13px] font-bold">문의 목록을 불러오는 중입니다.</span>
</div>
) : error ? (
<EmptyState title="채팅 서버 연결이 필요합니다." description={error} />
) : threads.length > 0 ? (
<div className="space-y-2.5">
{threads.map((thread) => (
<ThreadCard key={thread.id} accessToken={session.accessToken} thread={thread} onOpen={() => handleOpenThread(thread.id)} />
))}
</div>
) : (
<EmptyState title="대기 중인 문의가 없습니다." description="수강생이 강사 문의를 남기면 이곳에 표시됩니다." />
)}
</div>
</>
)}
</aside>
);
}
function ThreadCard({ accessToken, thread, onOpen }: { accessToken: string; thread: InstructorChatThread; onOpen: () => void }) {
return (
<button
type="button"
className="group w-full cursor-pointer rounded-lg border border-[#c7d2e0] bg-white px-4 py-3 text-left shadow-[0_10px_28px_rgba(15,23,42,0.05)] outline-none transition hover:border-[#818cf8] hover:bg-[#f8faff] hover:shadow-[0_16px_36px_rgba(79,70,229,0.14)] focus-visible:border-[#4f46e5] focus-visible:ring-2 focus-visible:ring-[#c7d2fe] active:bg-[#eef2ff]"
role="button"
onClick={onOpen}
>
<div className="flex items-start gap-3">
<StudentAvatar accessToken={accessToken} thread={thread} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-[14px] font-black text-[#111827]">{thread.studentName}</p>
{thread.status === 'WAITING_INSTRUCTOR' ? <StatusBadge status={thread.status} /> : null}
</div>
<p className="mt-1 line-clamp-2 text-[12px] font-bold leading-5 text-[#64748b]">{thread.lastMessage}</p>
<div className="mt-3 flex items-center gap-2 text-[11px] font-bold text-[#94a3b8]">
{thread.groupName ? <span>{thread.groupName}</span> : null}
{thread.week ? <span>{thread.week}주차</span> : null}
{thread.lastMessageAt ? <span>{formatDate(thread.lastMessageAt)}</span> : null}
</div>
</div>
<span className="mt-1 grid h-8 w-8 shrink-0 place-items-center rounded-md text-[#94a3b8] transition group-hover:bg-[#eef2ff] group-hover:text-[#4f46e5]">
<ChevronRight size={17} aria-hidden="true" />
</span>
</div>
</button>
);
}
function StudentAvatar({ accessToken, thread }: { accessToken: string; thread: InstructorChatThread }) {
const [avatarSource, setAvatarSource] = useState<string | null>(null);
useEffect(() => {
if (!thread.studentAvatarUrl) {
setAvatarSource(null);
return;
}
let revoked = false;
let objectUrl: string | null = null;
fetch(thread.studentAvatarUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
.then((response) => (response.ok ? response.blob() : null))
.then((blob) => {
if (!blob || revoked) return;
objectUrl = URL.createObjectURL(blob);
setAvatarSource(objectUrl);
})
.catch(() => {
if (!revoked) setAvatarSource(null);
});
return () => {
revoked = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [accessToken, thread.studentAvatarUrl]);
return (
<span className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-full bg-[#eef2ff] text-[#4f46e5] transition group-hover:bg-[#4f46e5] group-hover:text-white">
{avatarSource ? (
<img className="h-full w-full object-cover" src={avatarSource} alt="" />
) : (
<UserRound size={18} aria-hidden="true" />
)}
</span>
);
}
function ChatRoom({
thread,
loading,
onBack,
onClose,
onSend,
}: {
thread: InstructorChatThread;
loading: boolean;
onBack: () => void;
onClose: () => void;
onSend: (threadId: string, content: string) => Promise<void>;
}) {
const messages = thread.messages.length > 0 ? thread.messages : createFallbackMessages(thread);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const messageScrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const target = messageScrollRef.current;
if (!target || loading) return;
target.scrollTo({
top: target.scrollHeight,
behavior: 'smooth',
});
}, [loading, messages.length, thread.id]);
const handleSubmit = async () => {
const content = draft.trim();
if (!content || sending) return;
setSending(true);
setSendError(null);
try {
await onSend(thread.id, content);
setDraft('');
} catch (reason) {
setSendError(reason instanceof Error ? reason.message : '강사 답변을 저장하지 못했습니다.');
} finally {
setSending(false);
}
};
return (
<>
<div className="flex h-[68px] items-center gap-2 border-b border-[#cbd5e1] px-4">
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="목록으로 돌아가기" title="목록으로 돌아가기" onClick={onBack}>
<ArrowLeft size={17} aria-hidden="true" />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-[15px] font-black text-[#0f172a]">{thread.studentName}</p>
{thread.status === 'WAITING_INSTRUCTOR' ? <StatusBadge status={thread.status} /> : null}
</div>
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
{[thread.groupName, thread.week ? `${thread.week}주차` : null].filter(Boolean).join(' · ') || '강사 문의'}
</p>
</div>
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="채팅 닫기" title="채팅 닫기" onClick={onClose}>
<X size={17} aria-hidden="true" />
</button>
</div>
<div ref={messageScrollRef} className="flex-1 overflow-y-auto overscroll-contain px-5 py-5">
{loading ? (
<div className="flex h-full items-center justify-center text-[#64748b]">
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
<span className="text-[13px] font-bold">채팅방을 불러오는 중입니다.</span>
</div>
) : (
<div className="space-y-3">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</div>
)}
</div>
<div className="border-t border-[#cbd5e1] bg-white/86 p-4">
<div>
<div className="flex overflow-hidden rounded-lg border border-[#dbe3ef] bg-[#f8fafc]">
<textarea
className="h-[82px] min-w-0 flex-1 resize-none bg-transparent px-3 py-2 text-[13px] font-bold leading-5 text-[#111827] outline-none placeholder:text-[#94a3b8]"
value={draft}
placeholder="수강생에게 답변을 입력하세요."
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSubmit();
}
}}
/>
<button
className="grid w-12 shrink-0 place-items-center bg-[#4f46e5] text-white hover:bg-[#4338ca] disabled:bg-[#cbd5e1]"
type="button"
disabled={!draft.trim() || sending}
onClick={handleSubmit}
aria-label={sending ? '전송 중' : '전송'}
title={sending ? '전송 중' : '전송'}
>
{sending ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Send size={16} aria-hidden="true" />}
</button>
</div>
{sendError ? <p className="mt-1 text-[11px] font-bold text-[#e11d48]">{sendError}</p> : null}
</div>
</div>
</>
);
}
function MessageBubble({ message }: { message: InstructorChatMessage }) {
const isInstructor = message.senderType === 'INSTRUCTOR';
const isStudent = message.senderType === 'STUDENT';
const senderLabel = message.senderName?.trim() || getSenderLabel(message.senderType);
return (
<div className={`flex ${isInstructor ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[82%] rounded-lg px-3.5 py-2.5 ${
isInstructor
? 'bg-[#4f46e5] text-white'
: isStudent
? 'border border-[#c7d2e0] bg-white text-[#111827]'
: 'border border-[#e0e7ff] bg-[#eef2ff] text-[#3730a3]'
}`}
>
<div className="mb-1 flex items-center gap-2">
<span className={`text-[10px] font-black ${isInstructor ? 'text-white/78' : 'text-[#64748b]'}`}>{senderLabel}</span>
{message.createdAt ? <span className={`text-[10px] font-bold ${isInstructor ? 'text-white/58' : 'text-[#94a3b8]'}`}>{formatDate(message.createdAt)}</span> : null}
</div>
<p className="whitespace-pre-wrap text-[13px] font-bold leading-5">{message.content}</p>
</div>
</div>
);
}
function EmptyState({ title, description }: { title: string; description: string }) {
return (
<div className="flex h-full flex-col items-center justify-center rounded-lg border border-dashed border-[#cbd5e1] bg-white/58 px-6 text-center">
<span className="mb-3 grid h-11 w-11 place-items-center rounded-full bg-[#eef2ff] text-[#4f46e5]">
<MessageCircle size={20} aria-hidden="true" />
</span>
<p className="text-[14px] font-black text-[#111827]">{title}</p>
<p className="mt-2 text-[12px] font-bold leading-5 text-[#64748b]">{description}</p>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const label = getStatusLabel(status);
const tone = status === 'WAITING_INSTRUCTOR' ? 'bg-[#fff1f2] text-[#be123c] border-[#fecdd3]' : 'bg-[#eef2ff] text-[#4f46e5] border-[#c7d2fe]';
return <span className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-black ${tone}`}>{label}</span>;
}
function getStatusLabel(status: string): string {
if (status === 'WAITING_INSTRUCTOR') return '답변 대기';
if (status === 'OPEN') return '진행 중';
if (status === 'RESOLVED') return '완료';
if (status === 'ARCHIVED') return '보관';
return status;
}
function getSenderLabel(senderType: string): string {
if (senderType === 'STUDENT') return '수강생';
if (senderType === 'INSTRUCTOR') return '강사';
if (senderType === 'AI') return 'AI';
return '시스템';
}
function createFallbackMessages(thread: InstructorChatThread): InstructorChatMessage[] {
return [
{
id: `${thread.id}-last`,
senderType: 'STUDENT',
content: thread.lastMessage,
createdAt: thread.lastMessageAt,
},
];
}
function replaceThread(threads: InstructorChatThread[], thread: InstructorChatThread): InstructorChatThread[] {
const exists = threads.some((item) => item.id === thread.id);
if (!exists) return [thread, ...threads];
return threads.map((item) => (item.id === thread.id ? thread : item));
}
function formatDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}