feat: Add Chat UX improvements with notifications and @mention support

- Add ActionBar component with expandable toolbar for mobile
- Add @mention functionality with autocomplete dropdown
- Add browser notification system (push, sound, vibration)
- Add NotificationSettings modal for user preferences
- Add mention badges on room list cards
- Add ReportPreview with Markdown rendering and copy/download
- Add message copy functionality with hover actions
- Add backend mentions field to messages with Alembic migration
- Add lots field to rooms, remove templates
- Optimize WebSocket database session handling
- Various UX polish (animations, accessibility)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-08 08:20:37 +08:00
parent 92834dbe0e
commit 599802b818
72 changed files with 6810 additions and 702 deletions

View File

@@ -8,6 +8,8 @@ import {
useUpdateMemberRole,
useRemoveMember,
usePermanentDeleteRoom,
useAddLot,
useRemoveLot,
} from '../hooks/useRooms'
import { useMessages } from '../hooks/useMessages'
import { useWebSocket } from '../hooks/useWebSocket'
@@ -16,12 +18,18 @@ import { useGenerateReport, useDownloadReport } from '../hooks/useReports'
import { useUserSearch } from '../hooks/useUsers'
import { useIsMobile } from '../hooks/useMediaQuery'
import { filesService } from '../services/files'
import { notificationService } from '../services/notification'
import { useChatStore } from '../stores/chatStore'
import { useMentionStore } from '../stores/mentionStore'
import { useAuthStore, useIsAdmin } from '../stores/authStore'
import { Breadcrumb } from '../components/common'
import { MobileHeader, BottomToolbar, SlidePanel } from '../components/mobile'
import { MobileHeader, SlidePanel } from '../components/mobile'
import { ActionBar } from '../components/chat/ActionBar'
import { MentionInput, highlightMentions } from '../components/chat/MentionInput'
import { NotificationSettings } from '../components/chat/NotificationSettings'
import ReportProgress from '../components/report/ReportProgress'
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus } from '../types'
import { formatMessageTime } from '../utils/datetime'
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus, Message } from '../types'
const statusColors: Record<RoomStatus, string> = {
active: 'bg-green-100 text-green-800',
@@ -56,6 +64,7 @@ export default function RoomDetail() {
const { data: messagesData, isLoading: messagesLoading } = useMessages(roomId || '', { limit: 50 })
const { messages, connectionStatus, typingUsers, onlineUsers, setMessages, setCurrentRoom } = useChatStore()
const { addMention, clearMentions } = useMentionStore()
// Handle room deleted event from WebSocket
const handleRoomDeleted = useCallback((deletedRoomId: string) => {
@@ -65,9 +74,32 @@ export default function RoomDetail() {
}
}, [roomId, navigate])
// Handle new message notification
const handleNewMessage = useCallback((message: Message) => {
// Don't notify for own messages
if (message.sender_id === user?.username) return
// Check if current user is mentioned
const isMentioned = message.content.toLowerCase().includes(`@${user?.username?.toLowerCase()}`)
// Track mention in store if tab is not focused
if (isMentioned && !document.hasFocus() && roomId) {
addMention(roomId)
}
// Trigger notification
notificationService.notifyNewMessage(
message.sender_display_name || message.sender_id,
message.content,
roomId || '',
room?.title || 'Chat Room',
isMentioned
)
}, [user?.username, roomId, room?.title, addMention])
const { sendTextMessage, sendTyping, editMessage, deleteMessage, addReaction, removeReaction } = useWebSocket(
roomId || null,
{ onRoomDeleted: handleRoomDeleted }
{ onRoomDeleted: handleRoomDeleted, onMessage: handleNewMessage }
)
// Mutations
@@ -76,6 +108,8 @@ export default function RoomDetail() {
const updateMemberRole = useUpdateMemberRole(roomId || '')
const removeMember = useRemoveMember(roomId || '')
const permanentDeleteRoom = usePermanentDeleteRoom()
const addLot = useAddLot(roomId || '')
const removeLot = useRemoveLot(roomId || '')
// File hooks
const { data: filesData, isLoading: filesLoading } = useFiles(roomId || '')
@@ -102,6 +136,7 @@ export default function RoomDetail() {
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
const [editContent, setEditContent] = useState('')
const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null)
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [previewFile, setPreviewFile] = useState<FileMetadata | null>(null)
@@ -113,6 +148,8 @@ export default function RoomDetail() {
const [showUserDropdown, setShowUserDropdown] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [deleteConfirmInput, setDeleteConfirmInput] = useState('')
const [newLotInput, setNewLotInput] = useState('')
const [showNotificationSettings, setShowNotificationSettings] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const typingTimeoutRef = useRef<number | null>(null)
const searchTimeoutRef = useRef<number | null>(null)
@@ -138,15 +175,17 @@ export default function RoomDetail() {
}
}, [userSearchQuery])
// Initialize room
// Initialize room and clear mention badges
useEffect(() => {
if (roomId) {
setCurrentRoom(roomId)
// Clear unread mentions when entering the room
clearMentions(roomId)
}
return () => {
setCurrentRoom(null)
}
}, [roomId, setCurrentRoom])
}, [roomId, setCurrentRoom, clearMentions])
// Load initial messages
useEffect(() => {
@@ -160,24 +199,6 @@ export default function RoomDetail() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Handle typing indicator
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setMessageInput(e.target.value)
// Send typing indicator
sendTyping(true)
// Clear previous timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
// Stop typing after 2 seconds of inactivity
typingTimeoutRef.current = window.setTimeout(() => {
sendTyping(false)
}, 2000)
}
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault()
if (!messageInput.trim()) return
@@ -210,6 +231,16 @@ export default function RoomDetail() {
}
}
const handleCopyMessage = async (messageId: string, content: string) => {
try {
await navigator.clipboard.writeText(content)
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (err) {
console.error('Failed to copy message:', err)
}
}
const handleAddReaction = (messageId: string, emoji: string) => {
addReaction(messageId, emoji)
setShowEmojiPickerFor(null)
@@ -253,6 +284,21 @@ export default function RoomDetail() {
}
}
// LOT handlers
const handleAddLot = (e: React.FormEvent) => {
e.preventDefault()
if (!newLotInput.trim()) return
addLot.mutate(newLotInput.trim(), {
onSuccess: () => setNewLotInput(''),
})
}
const handleRemoveLot = (lot: string) => {
if (window.confirm(`Remove LOT "${lot}"?`)) {
removeLot.mutate(lot)
}
}
// Permanent delete room (admin only)
const handlePermanentDelete = () => {
if (!roomId || !room) return
@@ -361,21 +407,52 @@ export default function RoomDetail() {
}
}
// Listen for WebSocket report progress updates
// Listen for WebSocket report progress updates with polling fallback
useEffect(() => {
// This effect sets up listening for report progress via WebSocket
// The actual WebSocket handling should be done in the useWebSocket hook
// For now, we'll poll the report status if a report is being generated
if (!showReportProgress || !reportProgress.reportId) return
if (!showReportProgress || !reportProgress.reportId || !roomId) return
if (reportProgress.status === 'completed' || reportProgress.status === 'failed') return
// Status message translations
const statusMessages: Record<string, string> = {
pending: '準備中...',
collecting_data: '正在收集聊天室資料...',
generating_content: 'AI 正在分析並生成報告內容...',
assembling_document: '正在組裝報告文件...',
completed: '報告生成完成!',
failed: '報告生成失敗',
}
// Poll every 2 seconds for status updates (fallback for WebSocket)
const pollInterval = setInterval(async () => {
// The WebSocket should handle this, but we keep polling as fallback
}, 2000)
const pollStatus = async () => {
try {
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL || '/api'}/rooms/${roomId}/reports/${reportProgress.reportId}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem('auth-storage') ? JSON.parse(localStorage.getItem('auth-storage')!).state?.token : ''}`,
},
}
)
if (response.ok) {
const report = await response.json()
setReportProgress((prev) => ({
...prev,
status: report.status,
message: statusMessages[report.status] || prev.message,
error: report.error_message,
}))
}
} catch (error) {
console.error('Failed to poll report status:', error)
}
}
// Poll immediately, then every 2 seconds
pollStatus()
const pollInterval = setInterval(pollStatus, 2000)
return () => clearInterval(pollInterval)
}, [showReportProgress, reportProgress.reportId, reportProgress.status])
}, [showReportProgress, reportProgress.reportId, reportProgress.status, roomId])
if (roomLoading) {
return (
@@ -426,6 +503,7 @@ export default function RoomDetail() {
onGenerateReport={handleGenerateReport}
onStatusChange={handleStatusChange}
onPermanentDelete={() => setShowDeleteConfirm(true)}
onNotificationSettings={() => setShowNotificationSettings(true)}
/>
) : (
<header className="bg-white shadow-sm flex-shrink-0">
@@ -453,6 +531,71 @@ export default function RoomDetail() {
</span>
{room.location && <span className="text-gray-500">{room.location}</span>}
</div>
{/* LOT Display */}
{room.lots && room.lots.length > 0 && (
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500">LOT:</span>
<div className="flex flex-wrap gap-1">
{room.lots.map((lot) => (
<span
key={lot}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs"
>
{lot}
{permissions?.can_write && (
<button
onClick={() => handleRemoveLot(lot)}
className="hover:text-purple-900"
title="Remove LOT"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</span>
))}
</div>
{permissions?.can_write && (
<form onSubmit={handleAddLot} className="flex items-center gap-1">
<input
type="text"
value={newLotInput}
onChange={(e) => setNewLotInput(e.target.value)}
placeholder="Add LOT"
className="w-24 px-2 py-0.5 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-purple-500 focus:border-purple-500 outline-none"
/>
<button
type="submit"
disabled={!newLotInput.trim() || addLot.isPending}
className="px-2 py-0.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
>
+
</button>
</form>
)}
</div>
)}
{/* Show add LOT form when no LOTs exist */}
{(!room.lots || room.lots.length === 0) && permissions?.can_write && (
<form onSubmit={handleAddLot} className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500">LOT:</span>
<input
type="text"
value={newLotInput}
onChange={(e) => setNewLotInput(e.target.value)}
placeholder="Add LOT batch number"
className="w-32 px-2 py-0.5 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-purple-500 focus:border-purple-500 outline-none"
/>
<button
type="submit"
disabled={!newLotInput.trim() || addLot.isPending}
className="px-2 py-0.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
>
+ Add
</button>
</form>
)}
</div>
</div>
<div className="flex items-center gap-4">
@@ -472,21 +615,15 @@ export default function RoomDetail() {
</span>
</div>
{/* Generate Report Button */}
{/* Notification Settings */}
<button
onClick={handleGenerateReport}
disabled={generateReport.isPending}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-purple-600 text-white rounded-md hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => setShowNotificationSettings(true)}
className="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded"
title="Notification settings"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
{generateReport.isPending ? '生成中...' : '生成報告'}
</button>
{/* Status Actions (Owner only) */}
@@ -517,38 +654,6 @@ export default function RoomDetail() {
Delete Permanently
</button>
)}
{/* Files Toggle */}
<button
onClick={handleFilesToggle}
className={`flex items-center gap-1 ${showFiles ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<span className="text-sm">Files</span>
</button>
{/* Members Toggle */}
<button
onClick={handleMembersToggle}
className={`flex items-center gap-1 ${showMembers ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<span className="text-sm">{room.member_count}</span>
</button>
</div>
</div>
</div>
@@ -588,7 +693,7 @@ export default function RoomDetail() {
>
{!isOwnMessage && (
<div className="text-xs font-medium text-gray-500 mb-1">
{message.sender_id}
{message.sender_display_name || message.sender_id}
</div>
)}
@@ -623,7 +728,7 @@ export default function RoomDetail() {
) : (
<>
<p className={isOwnMessage ? 'text-white' : 'text-gray-900'}>
{message.content}
{highlightMentions(message.content, user?.username)}
</p>
{/* Reactions Display */}
@@ -653,7 +758,7 @@ export default function RoomDetail() {
isOwnMessage ? 'text-blue-200' : 'text-gray-400'
}`}
>
{new Date(message.created_at).toLocaleTimeString()}
{formatMessageTime(message.created_at)}
{message.edited_at && ' (edited)'}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -685,6 +790,22 @@ export default function RoomDetail() {
</div>
)}
</div>
{/* Copy Message Button */}
<button
onClick={() => handleCopyMessage(message.message_id, message.content)}
className={`p-1 ${isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-gray-600'}`}
title={copiedMessageId === message.message_id ? 'Copied!' : 'Copy message'}
>
{copiedMessageId === message.message_id ? (
<svg className="w-3 h-3 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
{/* Edit/Delete (own messages only) */}
{isOwnMessage && (
<>
@@ -727,18 +848,60 @@ export default function RoomDetail() {
</div>
)}
{/* Message Input */}
{/* Action Bar - Above message input */}
<ActionBar
showFiles={showFiles}
showMembers={showMembers}
memberCount={room.member_count || 0}
onFilesToggle={handleFilesToggle}
onMembersToggle={handleMembersToggle}
canWrite={permissions?.can_write || false}
canManageMembers={permissions?.can_manage_members || false}
isGeneratingReport={generateReport.isPending}
uploadProgress={uploadProgress}
onFileSelect={handleFileUpload}
onGenerateReport={handleGenerateReport}
onAddMemberClick={() => setShowAddMember(true)}
isMobile={isMobile}
/>
{/* Message Input with @Mention Support */}
{permissions?.can_write && (
<form onSubmit={handleSendMessage} className={`p-4 bg-white border-t ${isMobile ? 'pb-2' : ''}`}>
<div className="flex gap-2">
<input
type="text"
<MentionInput
value={messageInput}
onChange={handleInputChange}
placeholder="Type a message..."
className={`flex-1 px-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none ${
isMobile ? 'py-3 text-base' : 'py-2'
}`}
onChange={(value) => {
setMessageInput(value)
// Trigger typing indicator
sendTyping(true)
// Clear previous timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
// Stop typing after 2 seconds of inactivity
typingTimeoutRef.current = window.setTimeout(() => {
sendTyping(false)
}, 2000)
}}
onSubmit={() => {
if (messageInput.trim()) {
sendTextMessage(messageInput.trim())
setMessageInput('')
// Clear typing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
sendTyping(false)
}
}}
members={(room.members || []).map((m) => ({
user_id: m.user_id,
display_name: m.user_id, // TODO: Add display_name to RoomMember API
role: m.role,
}))}
placeholder="Type a message... (@ to mention)"
isMobile={isMobile}
/>
<button
type="submit"
@@ -752,17 +915,6 @@ export default function RoomDetail() {
</div>
</form>
)}
{/* Bottom Toolbar - Mobile Only */}
{isMobile && (
<BottomToolbar
showFiles={showFiles}
showMembers={showMembers}
memberCount={room.member_count || 0}
onFilesToggle={handleFilesToggle}
onMembersToggle={handleMembersToggle}
/>
)}
</div>
{/* Members Sidebar - Desktop Only */}
@@ -977,11 +1129,11 @@ export default function RoomDetail() {
<div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
</div>
) : filesData?.files.length === 0 ? (
) : filesData?.files?.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No files uploaded yet</p>
) : (
<div className="space-y-2">
{filesData?.files.map((file) => (
{filesData?.files?.map((file) => (
<div
key={file.file_id}
className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded"
@@ -1253,11 +1405,11 @@ export default function RoomDetail() {
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div>
) : filesData?.files.length === 0 ? (
) : filesData?.files?.length === 0 ? (
<p className="text-base text-gray-500 text-center py-8">No files uploaded yet</p>
) : (
<div className="space-y-2">
{filesData?.files.map((file) => (
{filesData?.files?.map((file) => (
<div
key={file.file_id}
className="flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg"
@@ -1369,6 +1521,8 @@ export default function RoomDetail() {
message={reportProgress.message}
error={reportProgress.error}
reportId={reportProgress.reportId}
roomId={roomId}
reportTitle={room?.title}
onDownload={handleDownloadReport}
/>
@@ -1439,6 +1593,12 @@ export default function RoomDetail() {
</div>
</div>
)}
{/* Notification Settings Modal */}
<NotificationSettings
isOpen={showNotificationSettings}
onClose={() => setShowNotificationSettings(false)}
/>
</div>
)
}

View File

@@ -1,9 +1,11 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router'
import { useRooms, useCreateRoom, useRoomTemplates, useJoinRoom } from '../hooks/useRooms'
import { useRooms, useCreateRoom, useJoinRoom } from '../hooks/useRooms'
import { useAuthStore, useIsAdmin } from '../stores/authStore'
import { useMentionStore } from '../stores/mentionStore'
import { useIsMobile } from '../hooks/useMediaQuery'
import { Breadcrumb } from '../components/common'
import { formatRelativeTimeGMT8 } from '../utils/datetime'
import type { RoomStatus, IncidentType, SeverityLevel, CreateRoomRequest, Room } from '../types'
const statusColors: Record<RoomStatus, string> = {
@@ -42,6 +44,7 @@ export default function RoomList() {
const [myRoomsOnly, setMyRoomsOnly] = useState(false)
const joinRoom = useJoinRoom()
const unreadMentions = useMentionStore((state) => state.unreadMentions)
// Reset page when filters change
const handleStatusChange = (status: RoomStatus | '') => {
@@ -182,7 +185,7 @@ export default function RoomList() {
<div className="text-center py-12">
<p className="text-red-500">Failed to load rooms</p>
</div>
) : data?.rooms.length === 0 ? (
) : !data?.rooms?.length ? (
<div className="text-center py-12">
<p className="text-gray-500">No rooms found</p>
<button
@@ -195,13 +198,14 @@ export default function RoomList() {
) : (
<>
<div className={`grid gap-4 ${isMobile ? 'grid-cols-1' : 'md:grid-cols-2 lg:grid-cols-3'}`}>
{data?.rooms.map((room) => (
{data?.rooms?.map((room) => (
<RoomCard
key={room.room_id}
room={room}
onJoin={handleJoinRoom}
isJoining={joinRoom.isPending}
isMobile={isMobile}
mentionCount={unreadMentions[room.room_id] || 0}
/>
))}
</div>
@@ -249,11 +253,13 @@ function RoomCard({
onJoin,
isJoining,
isMobile,
mentionCount,
}: {
room: Room
onJoin: (e: React.MouseEvent, roomId: string) => void
isJoining: boolean
isMobile: boolean
mentionCount: number
}) {
const isMember = room.is_member || room.current_user_role !== null
@@ -261,10 +267,17 @@ function RoomCard({
<Link
to={isMember ? `/rooms/${room.room_id}` : '#'}
onClick={!isMember ? (e) => e.preventDefault() : undefined}
className={`bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow block ${
className={`bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow block relative ${
isMobile ? 'p-4' : 'p-4'
}`}
>
{/* Mention Badge */}
{mentionCount > 0 && (
<div className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full min-w-[20px] h-5 flex items-center justify-center px-1.5 shadow-sm">
{mentionCount > 99 ? '99+' : mentionCount}
</div>
)}
{/* Room Header */}
<div className="flex justify-between items-start mb-2">
<h3 className={`font-semibold text-gray-900 truncate flex-1 ${isMobile ? 'text-base' : ''}`}>
@@ -326,7 +339,7 @@ function RoomCard({
</button>
) : (
<span className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-xs'}`}>
{new Date(room.last_activity_at).toLocaleDateString()}
{formatRelativeTimeGMT8(room.last_activity_at)}
</span>
)}
</div>
@@ -343,18 +356,39 @@ function CreateRoomModal({ onClose }: { onClose: () => void }) {
const [location, setLocation] = useState('')
const createRoom = useCreateRoom()
// Templates loaded for future use (template selection feature)
useRoomTemplates()
const [lots, setLots] = useState<string[]>([''])
const handleAddLot = () => {
setLots([...lots, ''])
}
const handleRemoveLot = (index: number) => {
if (lots.length > 1) {
setLots(lots.filter((_, i) => i !== index))
} else {
setLots([''])
}
}
const handleLotChange = (index: number, value: string) => {
const newLots = [...lots]
newLots[index] = value
setLots(newLots)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Filter out empty LOT values
const validLots = lots.filter(lot => lot.trim() !== '')
const data: CreateRoomRequest = {
title,
incident_type: incidentType,
severity,
description: description || undefined,
location: location || undefined,
lots: validLots.length > 0 ? validLots : undefined,
}
createRoom.mutate(data, {
@@ -447,6 +481,46 @@ function CreateRoomModal({ onClose }: { onClose: () => void }) {
/>
</div>
{/* LOT Batch Numbers */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
LOT Batch Numbers
</label>
<div className="space-y-2">
{lots.map((lot, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
value={lot}
onChange={(e) => handleLotChange(index, e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
placeholder="e.g., LOT-2024-001"
/>
<button
type="button"
onClick={() => handleRemoveLot(index)}
className="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg"
title="Remove LOT"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
<button
type="button"
onClick={handleAddLot}
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add LOT
</button>
</div>
</div>
{/* Error */}
{createRoom.isError && (
<div className="bg-red-50 text-red-600 px-3 py-2 rounded-lg text-sm">