f558073ef8
- DB: messenger_rooms/participants/messages/reactions/files 테이블 생성 - Backend: REST API 9개 엔드포인트 + Socket.IO 실시간 핸들러 - Frontend: Gmail 스타일 FAB + 모달, 채팅방 목록, 채팅 패널 - 기능: DM/그룹/채널, 파일 첨부, 이모지 리액션, 멘션, 스레드 - 알림: 토스트 on/off 토글, FAB 읽지 않은 배지 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> [RAPID-fix] 메신저 API snake_case→camelCase 변환 및 Socket.IO URL 수정 - useRooms/useMessages/useCompanyUsers 훅에서 DB 응답 camelCase 변환 - Socket.IO 기본 연결 URL 3001 → 8080 수정 - runMigration.ts 마이그레이션 파일 경로 수정 (../../ → ../../../) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> [RAPID-fix] 방 생성 API camelCase/snake_case 호환 처리 - createRoom 컨트롤러에서 participantIds/type/name (camelCase) fallback 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> [RAPID-fix] 메시지 전송 API 추가 (sendMessage 라우트/컨트롤러 누락) - POST /api/messenger/rooms/:roomId/messages 라우트 등록 - MessengerController.sendMessage 메서드 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
173 lines
5.9 KiB
TypeScript
173 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { useCompanyUsers, useCreateRoom } from "@/hooks/useMessenger";
|
|
import { useMessengerContext } from "@/contexts/MessengerContext";
|
|
import { UserAvatar } from "./UserAvatar";
|
|
import { Check } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface NewRoomModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export function NewRoomModal({ open, onOpenChange }: NewRoomModalProps) {
|
|
const [tab, setTab] = useState<"dm" | "group" | "channel">("dm");
|
|
const [search, setSearch] = useState("");
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [roomName, setRoomName] = useState("");
|
|
const [channelDesc, setChannelDesc] = useState("");
|
|
|
|
const { data: users = [] } = useCompanyUsers();
|
|
const createRoom = useCreateRoom();
|
|
const { selectRoom } = useMessengerContext();
|
|
|
|
const filtered = users.filter(
|
|
(u) =>
|
|
u.userName.toLowerCase().includes(search.toLowerCase()) ||
|
|
(u.deptName && u.deptName.toLowerCase().includes(search.toLowerCase()))
|
|
);
|
|
|
|
const toggleUser = (userId: string) => {
|
|
if (tab === "dm") {
|
|
setSelectedIds([userId]);
|
|
} else {
|
|
setSelectedIds((prev) =>
|
|
prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
try {
|
|
const room = await createRoom.mutateAsync({
|
|
type: tab,
|
|
name: tab === "channel" ? roomName : tab === "group" ? roomName : undefined,
|
|
description: tab === "channel" ? channelDesc : undefined,
|
|
participantIds: selectedIds,
|
|
});
|
|
selectRoom(room.id);
|
|
onOpenChange(false);
|
|
reset();
|
|
} catch {
|
|
// handled by query
|
|
}
|
|
};
|
|
|
|
const reset = () => {
|
|
setSearch("");
|
|
setSelectedIds([]);
|
|
setRoomName("");
|
|
setChannelDesc("");
|
|
};
|
|
|
|
const canCreate =
|
|
selectedIds.length > 0 &&
|
|
(tab === "dm" || tab === "group" || (tab === "channel" && roomName.trim()));
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>새 대화</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<Tabs value={tab} onValueChange={(v) => { setTab(v as typeof tab); reset(); }}>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="dm" className="flex-1">DM</TabsTrigger>
|
|
<TabsTrigger value="group" className="flex-1">그룹</TabsTrigger>
|
|
<TabsTrigger value="channel" className="flex-1">채널</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{(tab === "group" || tab === "channel") && (
|
|
<div className="mt-3 space-y-2">
|
|
<Input
|
|
placeholder={tab === "group" ? "그룹 이름" : "채널 이름"}
|
|
value={roomName}
|
|
onChange={(e) => setRoomName(e.target.value)}
|
|
/>
|
|
{tab === "channel" && (
|
|
<Input
|
|
placeholder="채널 설명 (선택)"
|
|
value={channelDesc}
|
|
onChange={(e) => setChannelDesc(e.target.value)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<TabsContent value={tab} className="mt-3">
|
|
<Input
|
|
placeholder="사용자 검색..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="mb-2"
|
|
/>
|
|
|
|
{selectedIds.length > 0 && tab !== "dm" && (
|
|
<div className="flex flex-wrap gap-1 mb-2">
|
|
{selectedIds.map((id) => {
|
|
const u = users.find((x) => x.userId === id);
|
|
return u ? (
|
|
<span
|
|
key={id}
|
|
onClick={() => toggleUser(id)}
|
|
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary text-xs px-2 py-0.5 cursor-pointer hover:bg-primary/20"
|
|
>
|
|
{u.userName} ×
|
|
</span>
|
|
) : null;
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<ScrollArea className="h-60">
|
|
{filtered.map((u) => {
|
|
const selected = selectedIds.includes(u.userId);
|
|
return (
|
|
<button
|
|
key={u.userId}
|
|
onClick={() => toggleUser(u.userId)}
|
|
className={cn(
|
|
"w-full flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-left",
|
|
selected && "bg-accent"
|
|
)}
|
|
>
|
|
<UserAvatar photo={u.photo} name={u.userName} size="sm" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{u.userName}</div>
|
|
{u.deptName && (
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{u.deptName} {u.positionName && `/ ${u.positionName}`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{selected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<div className="flex justify-end mt-2">
|
|
<Button onClick={handleCreate} disabled={!canCreate || createRoom.isPending} size="sm">
|
|
{createRoom.isPending ? "생성 중..." : "대화 시작"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|