73bd4f2ba7
- rolesList/RoleFormModal/[id] 페이지에서 회사 선택 필터·라벨·SUPER_ADMIN 회사 컬럼 제거 - MessageInput/NewRoomModal에서 user.userName/deptName.toLowerCase 호출 시 옵셔널 체이닝 적용으로 null 사용자 방어 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
6.0 KiB
TypeScript
176 lines
6.0 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";
|
|
import type { UserStatus } from "@/hooks/useMessengerSocket";
|
|
|
|
interface NewRoomModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
userStatuses: Map<string, UserStatus>;
|
|
}
|
|
|
|
export function NewRoomModal({ open, onOpenChange, userStatuses }: 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 q = search.toLowerCase();
|
|
const filtered = users.filter(
|
|
(u) =>
|
|
u.userName?.toLowerCase().includes(q) ||
|
|
u.deptName?.toLowerCase().includes(q)
|
|
);
|
|
|
|
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" status={userStatuses.get(u.userId) ?? "offline"} />
|
|
<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>
|
|
);
|
|
}
|