feat: Initial commit - Task Reporter incident response system

Complete implementation of the production line incident response system (生產線異常即時反應系統) including:

Backend (FastAPI):
- User authentication with AD integration and session management
- Chat room management (create, list, update, members, roles)
- Real-time messaging via WebSocket (typing indicators, reactions)
- File storage with MinIO (upload, download, image preview)

Frontend (React + Vite):
- Authentication flow with token management
- Room list with filtering, search, and pagination
- Real-time chat interface with WebSocket
- File upload with drag-and-drop and image preview
- Member management and room settings
- Breadcrumb navigation
- 53 unit tests (Vitest)

Specifications:
- authentication: AD auth, sessions, JWT tokens
- chat-room: rooms, members, templates
- realtime-messaging: WebSocket, messages, reactions
- file-storage: MinIO integration, file management
- frontend-core: React SPA structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-01 17:42:52 +08:00
commit c8966477b9
135 changed files with 23269 additions and 0 deletions

87
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,87 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
import { useAuthStore } from './stores/authStore'
// Pages
import Login from './pages/Login'
import RoomList from './pages/RoomList'
import RoomDetail from './pages/RoomDetail'
import NotFound from './pages/NotFound'
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
retry: 1,
refetchOnWindowFocus: false,
},
},
})
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
// Public route wrapper (redirect to home if authenticated)
function PublicRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
if (isAuthenticated) {
return <Navigate to="/" replace />
}
return <>{children}</>
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route
path="/login"
element={
<PublicRoute>
<Login />
</PublicRoute>
}
/>
{/* Protected routes */}
<Route
path="/"
element={
<ProtectedRoute>
<RoomList />
</ProtectedRoute>
}
/>
<Route
path="/rooms/:roomId"
element={
<ProtectedRoute>
<RoomDetail />
</ProtectedRoute>
}
/>
{/* 404 */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
export default App

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import { render } from '../../test/test-utils'
import { Breadcrumb } from './Breadcrumb'
describe('Breadcrumb', () => {
it('should render single item', () => {
render(<Breadcrumb items={[{ label: 'Home' }]} />)
expect(screen.getByText('Home')).toBeInTheDocument()
})
it('should render multiple items with separators', () => {
render(
<Breadcrumb
items={[
{ label: 'Home', href: '/' },
{ label: 'Rooms', href: '/rooms' },
{ label: 'Room 1' },
]}
/>
)
expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('Rooms')).toBeInTheDocument()
expect(screen.getByText('Room 1')).toBeInTheDocument()
})
it('should render links for items with href', () => {
render(
<Breadcrumb
items={[
{ label: 'Home', href: '/' },
{ label: 'Current' },
]}
/>
)
const homeLink = screen.getByRole('link', { name: 'Home' })
expect(homeLink).toHaveAttribute('href', '/')
})
it('should not render link for last item', () => {
render(
<Breadcrumb
items={[
{ label: 'Home', href: '/' },
{ label: 'Current', href: '/current' },
]}
/>
)
// Last item should be text, not a link
const currentText = screen.getByText('Current')
expect(currentText.tagName).not.toBe('A')
})
it('should have aria-label for accessibility', () => {
render(<Breadcrumb items={[{ label: 'Home' }]} />)
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb')
})
})

View File

@@ -0,0 +1,54 @@
import { Link } from 'react-router'
export interface BreadcrumbItem {
label: string
href?: string
}
interface BreadcrumbProps {
items: BreadcrumbItem[]
}
export function Breadcrumb({ items }: BreadcrumbProps) {
return (
<nav className="flex items-center text-sm text-gray-500" aria-label="Breadcrumb">
<ol className="flex items-center space-x-2">
{items.map((item, index) => {
const isLast = index === items.length - 1
return (
<li key={index} className="flex items-center">
{index > 0 && (
<svg
className="w-4 h-4 mx-2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
)}
{isLast || !item.href ? (
<span className={isLast ? 'text-gray-900 font-medium' : ''}>
{item.label}
</span>
) : (
<Link
to={item.href}
className="hover:text-gray-700 hover:underline"
>
{item.label}
</Link>
)}
</li>
)
})}
</ol>
</nav>
)
}

View File

@@ -0,0 +1 @@
export { Breadcrumb, type BreadcrumbItem } from './Breadcrumb'

View File

@@ -0,0 +1,33 @@
export { useAuth } from './useAuth'
export {
useRooms,
useRoom,
useRoomTemplates,
useRoomPermissions,
useCreateRoom,
useUpdateRoom,
useDeleteRoom,
useAddMember,
useUpdateMemberRole,
useRemoveMember,
useTransferOwnership,
roomKeys,
} from './useRooms'
export {
useMessages,
useInfiniteMessages,
useSearchMessages,
useCreateMessage,
useOnlineUsers,
useTypingUsers,
messageKeys,
} from './useMessages'
export { useWebSocket } from './useWebSocket'
export {
useFiles,
useFile,
useUploadFile,
useDeleteFile,
useDownloadFile,
fileKeys,
} from './useFiles'

View File

@@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useAuth } from './useAuth'
import { useAuthStore } from '../stores/authStore'
import { createWrapper } from '../test/test-utils'
// Mock react-router
const mockNavigate = vi.fn()
vi.mock('react-router', async () => {
const actual = await vi.importActual('react-router')
return {
...actual,
useNavigate: () => mockNavigate,
}
})
// Mock authService
vi.mock('../services/auth', () => ({
authService: {
login: vi.fn(),
logout: vi.fn(),
},
}))
import { authService } from '../services/auth'
describe('useAuth', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset auth store
useAuthStore.setState({
token: null,
user: null,
isAuthenticated: false,
})
})
describe('initial state', () => {
it('should return initial unauthenticated state', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
})
expect(result.current.token).toBeNull()
expect(result.current.user).toBeNull()
expect(result.current.isAuthenticated).toBe(false)
expect(result.current.isLoggingIn).toBe(false)
expect(result.current.isLoggingOut).toBe(false)
})
it('should return authenticated state when user is logged in', () => {
useAuthStore.setState({
token: 'test-token',
user: { username: 'testuser', display_name: 'Test User' },
isAuthenticated: true,
})
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
})
expect(result.current.token).toBe('test-token')
expect(result.current.user?.username).toBe('testuser')
expect(result.current.isAuthenticated).toBe(true)
})
})
describe('login', () => {
it('should login successfully and navigate to home', async () => {
vi.mocked(authService.login).mockResolvedValue({
token: 'new-token',
display_name: 'Test User',
})
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
})
await result.current.login({ username: 'testuser', password: 'password' })
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true)
})
expect(authService.login).toHaveBeenCalledWith({
username: 'testuser',
password: 'password',
})
expect(mockNavigate).toHaveBeenCalledWith('/')
})
it('should handle login error', async () => {
const loginError = new Error('Invalid credentials')
vi.mocked(authService.login).mockRejectedValue(loginError)
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
})
await expect(
result.current.login({ username: 'testuser', password: 'wrong' })
).rejects.toThrow('Invalid credentials')
expect(result.current.isAuthenticated).toBe(false)
})
})
describe('logout', () => {
it('should logout and navigate to login page', async () => {
vi.mocked(authService.logout).mockResolvedValue(undefined)
// Set initial authenticated state
useAuthStore.setState({
token: 'test-token',
user: { username: 'testuser', display_name: 'Test User' },
isAuthenticated: true,
})
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
})
result.current.logout()
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(false)
})
expect(mockNavigate).toHaveBeenCalledWith('/login')
})
it('should clear auth even if logout API fails', async () => {
vi.mocked(authService.logout).mockRejectedValue(new Error('Network error'))
useAuthStore.setState({
token: 'test-token',
user: { username: 'testuser', display_name: 'Test User' },
isAuthenticated: true,
})
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
})
result.current.logout()
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(false)
})
expect(mockNavigate).toHaveBeenCalledWith('/login')
})
})
})

View File

@@ -0,0 +1,48 @@
import { useMutation } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { useAuthStore } from '../stores/authStore'
import { authService } from '../services/auth'
import type { LoginRequest } from '../types'
export function useAuth() {
const navigate = useNavigate()
const { token, user, isAuthenticated, setAuth, clearAuth } = useAuthStore()
const loginMutation = useMutation({
mutationFn: async (credentials: LoginRequest) => {
const data = await authService.login(credentials)
return { ...data, username: credentials.username }
},
onSuccess: (data) => {
setAuth(data.token, data.display_name, data.username)
navigate('/')
},
})
const login = (credentials: LoginRequest) => {
return loginMutation.mutateAsync(credentials)
}
const logoutMutation = useMutation({
mutationFn: () => authService.logout(),
onSettled: () => {
clearAuth()
navigate('/login')
},
})
const logout = () => {
logoutMutation.mutate()
}
return {
token,
user,
isAuthenticated,
login,
logout,
isLoggingIn: loginMutation.isPending,
isLoggingOut: logoutMutation.isPending,
loginError: loginMutation.error,
}
}

View File

@@ -0,0 +1,63 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { filesService, type FileFilters } from '../services/files'
// Query keys
export const fileKeys = {
all: ['files'] as const,
lists: () => [...fileKeys.all, 'list'] as const,
list: (roomId: string, filters?: FileFilters) => [...fileKeys.lists(), roomId, filters] as const,
details: () => [...fileKeys.all, 'detail'] as const,
detail: (roomId: string, fileId: string) => [...fileKeys.details(), roomId, fileId] as const,
}
export function useFiles(roomId: string, filters?: FileFilters) {
return useQuery({
queryKey: fileKeys.list(roomId, filters),
queryFn: () => filesService.listFiles(roomId, filters),
enabled: !!roomId,
})
}
export function useFile(roomId: string, fileId: string) {
return useQuery({
queryKey: fileKeys.detail(roomId, fileId),
queryFn: () => filesService.getFile(roomId, fileId),
enabled: !!roomId && !!fileId,
})
}
export function useUploadFile(roomId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
file,
description,
onProgress,
}: {
file: File
description?: string
onProgress?: (progress: number) => void
}) => filesService.uploadFile(roomId, file, description, onProgress),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: fileKeys.list(roomId) })
},
})
}
export function useDeleteFile(roomId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (fileId: string) => filesService.deleteFile(roomId, fileId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: fileKeys.list(roomId) })
},
})
}
export function useDownloadFile(roomId: string) {
return useMutation({
mutationFn: (fileId: string) => filesService.downloadFile(roomId, fileId),
})
}

View File

@@ -0,0 +1,80 @@
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'
import { messagesService, type MessageFilters } from '../services/messages'
import type { CreateMessageRequest } from '../types'
// Query keys
export const messageKeys = {
all: ['messages'] as const,
lists: () => [...messageKeys.all, 'list'] as const,
list: (roomId: string, filters?: MessageFilters) => [...messageKeys.lists(), roomId, filters] as const,
infinite: (roomId: string) => [...messageKeys.all, 'infinite', roomId] as const,
search: (roomId: string, query: string) => [...messageKeys.all, 'search', roomId, query] as const,
online: (roomId: string) => [...messageKeys.all, 'online', roomId] as const,
typing: (roomId: string) => [...messageKeys.all, 'typing', roomId] as const,
}
export function useMessages(roomId: string, filters?: MessageFilters) {
return useQuery({
queryKey: messageKeys.list(roomId, filters),
queryFn: () => messagesService.getMessages(roomId, filters),
enabled: !!roomId,
})
}
export function useInfiniteMessages(roomId: string, limit = 50) {
return useInfiniteQuery({
queryKey: messageKeys.infinite(roomId),
queryFn: ({ pageParam }) =>
messagesService.getMessages(roomId, {
limit,
before: pageParam as string | undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => {
if (!lastPage.has_more || lastPage.messages.length === 0) {
return undefined
}
// Get the oldest message timestamp for pagination
const oldestMessage = lastPage.messages[0]
return oldestMessage?.created_at
},
enabled: !!roomId,
})
}
export function useSearchMessages(roomId: string, query: string) {
return useQuery({
queryKey: messageKeys.search(roomId, query),
queryFn: () => messagesService.searchMessages(roomId, query),
enabled: !!roomId && query.length > 0,
})
}
export function useCreateMessage(roomId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateMessageRequest) => messagesService.createMessage(roomId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: messageKeys.list(roomId) })
},
})
}
export function useOnlineUsers(roomId: string) {
return useQuery({
queryKey: messageKeys.online(roomId),
queryFn: () => messagesService.getOnlineUsers(roomId),
enabled: !!roomId,
refetchInterval: 30000, // Refresh every 30 seconds
})
}
export function useTypingUsers(roomId: string) {
return useQuery({
queryKey: messageKeys.typing(roomId),
queryFn: () => messagesService.getTypingUsers(roomId),
enabled: !!roomId,
refetchInterval: 5000, // Refresh every 5 seconds
})
}

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useRooms, useRoom, useCreateRoom } from './useRooms'
import { createWrapper } from '../test/test-utils'
// Mock roomsService
vi.mock('../services/rooms', () => ({
roomsService: {
listRooms: vi.fn(),
getRoom: vi.fn(),
createRoom: vi.fn(),
updateRoom: vi.fn(),
deleteRoom: vi.fn(),
addMember: vi.fn(),
updateMemberRole: vi.fn(),
removeMember: vi.fn(),
transferOwnership: vi.fn(),
getPermissions: vi.fn(),
getTemplates: vi.fn(),
},
}))
import { roomsService } from '../services/rooms'
const mockRoom = {
room_id: 'room-1',
title: 'Test Room',
incident_type: 'equipment_failure' as const,
severity: 'medium' as const,
status: 'active' as const,
created_by: 'user-1',
created_at: '2024-01-01T00:00:00Z',
last_activity_at: '2024-01-01T00:00:00Z',
last_updated_at: '2024-01-01T00:00:00Z',
member_count: 1,
}
describe('useRooms', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('useRooms (list)', () => {
it('should fetch rooms list', async () => {
const mockResponse = {
rooms: [mockRoom],
total: 1,
limit: 50,
offset: 0,
}
vi.mocked(roomsService.listRooms).mockResolvedValue(mockResponse)
const { result } = renderHook(() => useRooms(), {
wrapper: createWrapper(),
})
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.data).toEqual(mockResponse)
expect(roomsService.listRooms).toHaveBeenCalledWith(undefined)
})
it('should fetch rooms with filters', async () => {
const mockResponse = {
rooms: [mockRoom],
total: 1,
limit: 10,
offset: 0,
}
vi.mocked(roomsService.listRooms).mockResolvedValue(mockResponse)
const filters = { status: 'active' as const, limit: 10 }
const { result } = renderHook(() => useRooms(filters), {
wrapper: createWrapper(),
})
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(roomsService.listRooms).toHaveBeenCalledWith(filters)
})
it('should handle error', async () => {
vi.mocked(roomsService.listRooms).mockRejectedValue(new Error('Failed to fetch'))
const { result } = renderHook(() => useRooms(), {
wrapper: createWrapper(),
})
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBeDefined()
})
})
describe('useRoom (single)', () => {
it('should fetch single room by id', async () => {
vi.mocked(roomsService.getRoom).mockResolvedValue(mockRoom)
const { result } = renderHook(() => useRoom('room-1'), {
wrapper: createWrapper(),
})
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.data).toEqual(mockRoom)
expect(roomsService.getRoom).toHaveBeenCalledWith('room-1')
})
it('should not fetch if roomId is empty', async () => {
const { result } = renderHook(() => useRoom(''), {
wrapper: createWrapper(),
})
// Query should be disabled
expect(result.current.fetchStatus).toBe('idle')
expect(roomsService.getRoom).not.toHaveBeenCalled()
})
})
describe('useCreateRoom', () => {
it('should create a new room', async () => {
const newRoom = { ...mockRoom, room_id: 'room-new' }
vi.mocked(roomsService.createRoom).mockResolvedValue(newRoom)
const { result } = renderHook(() => useCreateRoom(), {
wrapper: createWrapper(),
})
const createData = {
title: 'New Room',
incident_type: 'equipment_failure' as const,
severity: 'medium' as const,
}
result.current.mutate(createData)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(roomsService.createRoom).toHaveBeenCalledWith(createData)
})
})
})

View File

@@ -0,0 +1,125 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { roomsService, type RoomFilters } from '../services/rooms'
import type { CreateRoomRequest, UpdateRoomRequest, MemberRole } from '../types'
// Query keys
export const roomKeys = {
all: ['rooms'] as const,
lists: () => [...roomKeys.all, 'list'] as const,
list: (filters: RoomFilters) => [...roomKeys.lists(), filters] as const,
details: () => [...roomKeys.all, 'detail'] as const,
detail: (id: string) => [...roomKeys.details(), id] as const,
templates: () => [...roomKeys.all, 'templates'] as const,
permissions: (id: string) => [...roomKeys.all, 'permissions', id] as const,
}
export function useRooms(filters?: RoomFilters) {
return useQuery({
queryKey: roomKeys.list(filters || {}),
queryFn: () => roomsService.listRooms(filters),
})
}
export function useRoom(roomId: string) {
return useQuery({
queryKey: roomKeys.detail(roomId),
queryFn: () => roomsService.getRoom(roomId),
enabled: !!roomId,
})
}
export function useRoomTemplates() {
return useQuery({
queryKey: roomKeys.templates(),
queryFn: () => roomsService.getTemplates(),
staleTime: 1000 * 60 * 5, // 5 minutes
})
}
export function useRoomPermissions(roomId: string) {
return useQuery({
queryKey: roomKeys.permissions(roomId),
queryFn: () => roomsService.getPermissions(roomId),
enabled: !!roomId,
})
}
export function useCreateRoom() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateRoomRequest) => roomsService.createRoom(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
},
})
}
export function useUpdateRoom(roomId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdateRoomRequest) => roomsService.updateRoom(roomId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
},
})
}
export function useDeleteRoom() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (roomId: string) => roomsService.deleteRoom(roomId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
},
})
}
export function useAddMember(roomId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ userId, role }: { userId: string; role: MemberRole }) =>
roomsService.addMember(roomId, userId, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
},
})
}
export function useUpdateMemberRole(roomId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ userId, role }: { userId: string; role: MemberRole }) =>
roomsService.updateMemberRole(roomId, userId, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
},
})
}
export function useRemoveMember(roomId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (userId: string) => roomsService.removeMember(roomId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
},
})
}
export function useTransferOwnership(roomId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (newOwnerId: string) => roomsService.transferOwnership(roomId, newOwnerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
},
})
}

View File

@@ -0,0 +1,276 @@
import { useEffect, useRef, useCallback } from 'react'
import { useChatStore } from '../stores/chatStore'
import { useAuthStore } from '../stores/authStore'
import type {
WebSocketMessageIn,
MessageBroadcast,
SystemBroadcast,
TypingBroadcast,
FileUploadedBroadcast,
FileDeletedBroadcast,
Message,
} from '../types'
const RECONNECT_DELAY = 1000
const MAX_RECONNECT_DELAY = 30000
const RECONNECT_MULTIPLIER = 2
interface UseWebSocketOptions {
onMessage?: (message: Message) => void
onFileUploaded?: (data: FileUploadedBroadcast) => void
onFileDeleted?: (data: FileDeletedBroadcast) => void
}
export function useWebSocket(roomId: string | null, options?: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null)
const reconnectDelayRef = useRef(RECONNECT_DELAY)
const token = useAuthStore((state) => state.token)
const {
setConnectionStatus,
addMessage,
updateMessage,
removeMessage,
setUserTyping,
addOnlineUser,
removeOnlineUser,
} = useChatStore()
const connect = useCallback(() => {
if (!roomId || !token) {
return
}
// Close existing connection
if (wsRef.current) {
wsRef.current.close()
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}/api/ws/${roomId}?token=${token}`
setConnectionStatus('connecting')
const ws = new WebSocket(wsUrl)
ws.onopen = () => {
setConnectionStatus('connected')
reconnectDelayRef.current = RECONNECT_DELAY
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
handleMessage(data)
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}
ws.onerror = () => {
setConnectionStatus('error')
}
ws.onclose = () => {
setConnectionStatus('disconnected')
scheduleReconnect()
}
wsRef.current = ws
}, [roomId, token, setConnectionStatus])
const handleMessage = useCallback(
(data: unknown) => {
const msg = data as { type: string }
switch (msg.type) {
case 'message':
case 'edit_message': {
const messageBroadcast = data as MessageBroadcast
const message: Message = {
message_id: messageBroadcast.message_id,
room_id: messageBroadcast.room_id,
sender_id: messageBroadcast.sender_id,
content: messageBroadcast.content,
message_type: messageBroadcast.message_type,
metadata: messageBroadcast.metadata,
created_at: messageBroadcast.created_at,
edited_at: messageBroadcast.edited_at,
sequence_number: messageBroadcast.sequence_number,
}
if (msg.type === 'message') {
addMessage(message)
options?.onMessage?.(message)
} else {
updateMessage(message.message_id, message)
}
break
}
case 'delete_message': {
const deleteMsg = data as { message_id: string }
removeMessage(deleteMsg.message_id)
break
}
case 'typing': {
const typingData = data as TypingBroadcast
setUserTyping(typingData.user_id, typingData.is_typing)
break
}
case 'system': {
const systemData = data as SystemBroadcast
if (systemData.event === 'user_joined') {
addOnlineUser(systemData.user_id || '')
} else if (systemData.event === 'user_left') {
removeOnlineUser(systemData.user_id || '')
}
break
}
case 'file_uploaded': {
const fileData = data as FileUploadedBroadcast
options?.onFileUploaded?.(fileData)
break
}
case 'file_deleted': {
const fileData = data as FileDeletedBroadcast
options?.onFileDeleted?.(fileData)
break
}
case 'ack':
// Message acknowledgment - could update optimistic UI
break
case 'error':
console.error('WebSocket error message:', data)
break
default:
console.log('Unknown WebSocket message type:', msg.type)
}
},
[addMessage, updateMessage, removeMessage, setUserTyping, addOnlineUser, removeOnlineUser, options]
)
const scheduleReconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
reconnectTimeoutRef.current = window.setTimeout(() => {
reconnectDelayRef.current = Math.min(
reconnectDelayRef.current * RECONNECT_MULTIPLIER,
MAX_RECONNECT_DELAY
)
connect()
}, reconnectDelayRef.current)
}, [connect])
const sendMessage = useCallback((message: WebSocketMessageIn) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message))
}
}, [])
const sendTextMessage = useCallback(
(content: string, metadata?: Record<string, unknown>) => {
sendMessage({
type: 'message',
content,
message_type: 'text',
metadata,
})
},
[sendMessage]
)
const sendTyping = useCallback(
(isTyping: boolean) => {
sendMessage({
type: 'typing',
is_typing: isTyping,
})
},
[sendMessage]
)
const editMessage = useCallback(
(messageId: string, content: string) => {
sendMessage({
type: 'edit_message',
message_id: messageId,
content,
})
},
[sendMessage]
)
const deleteMessage = useCallback(
(messageId: string) => {
sendMessage({
type: 'delete_message',
message_id: messageId,
})
},
[sendMessage]
)
const addReaction = useCallback(
(messageId: string, emoji: string) => {
sendMessage({
type: 'add_reaction',
message_id: messageId,
emoji,
})
},
[sendMessage]
)
const removeReaction = useCallback(
(messageId: string, emoji: string) => {
sendMessage({
type: 'remove_reaction',
message_id: messageId,
emoji,
})
},
[sendMessage]
)
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
}, [])
// Connect when roomId changes
useEffect(() => {
if (roomId) {
connect()
}
return () => {
disconnect()
}
}, [roomId, connect, disconnect])
return {
sendTextMessage,
sendTyping,
editMessage,
deleteMessage,
addReaction,
removeReaction,
disconnect,
reconnect: connect,
}
}

17
frontend/src/index.css Normal file
View File

@@ -0,0 +1,17 @@
@import "tailwindcss";
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
#root {
min-height: 100vh;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,183 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '../test/test-utils'
import Login from './Login'
import { useAuthStore } from '../stores/authStore'
// Mock react-router
const mockNavigate = vi.fn()
vi.mock('react-router', async () => {
const actual = await vi.importActual('react-router')
return {
...actual,
useNavigate: () => mockNavigate,
}
})
// Mock authService
vi.mock('../services/auth', () => ({
authService: {
login: vi.fn(),
},
}))
import { authService } from '../services/auth'
describe('Login', () => {
beforeEach(() => {
vi.clearAllMocks()
useAuthStore.setState({
token: null,
user: null,
isAuthenticated: false,
})
})
describe('rendering', () => {
it('should render login form', () => {
render(<Login />)
expect(screen.getByText('Task Reporter')).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
})
it('should have empty inputs initially', () => {
render(<Login />)
expect(screen.getByLabelText(/email/i)).toHaveValue('')
expect(screen.getByLabelText(/password/i)).toHaveValue('')
})
})
describe('form interaction', () => {
it('should update input values when typing', async () => {
const user = userEvent.setup()
render(<Login />)
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
await user.type(emailInput, 'test@example.com')
await user.type(passwordInput, 'password123')
expect(emailInput).toHaveValue('test@example.com')
expect(passwordInput).toHaveValue('password123')
})
it('should submit form with credentials', async () => {
vi.mocked(authService.login).mockResolvedValue({
token: 'test-token',
display_name: 'Test User',
})
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(authService.login).toHaveBeenCalledWith({
username: 'testuser',
password: 'password123',
})
})
})
it('should navigate to home on successful login', async () => {
vi.mocked(authService.login).mockResolvedValue({
token: 'test-token',
display_name: 'Test User',
})
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/')
})
})
it('should update auth store on successful login', async () => {
vi.mocked(authService.login).mockResolvedValue({
token: 'test-token',
display_name: 'Test User',
})
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
const state = useAuthStore.getState()
expect(state.token).toBe('test-token')
expect(state.isAuthenticated).toBe(true)
})
})
})
describe('error handling', () => {
it('should display error message on login failure', async () => {
vi.mocked(authService.login).mockRejectedValue(new Error('Invalid credentials'))
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'wrongpassword')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument()
})
})
})
describe('loading state', () => {
it('should show loading state during login', async () => {
// Make login hang to test loading state
vi.mocked(authService.login).mockImplementation(
() => new Promise(() => {}) // Never resolves
)
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(screen.getByText(/logging in/i)).toBeInTheDocument()
})
})
it('should disable button during login', async () => {
vi.mocked(authService.login).mockImplementation(
() => new Promise(() => {})
)
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(screen.getByRole('button')).toBeDisabled()
})
})
})
})

View File

@@ -0,0 +1,136 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { useAuthStore } from '../stores/authStore'
import { authService } from '../services/auth'
export default function Login() {
const navigate = useNavigate()
const setAuth = useAuthStore((state) => state.setAuth)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const loginMutation = useMutation({
mutationFn: () => authService.login({ username, password }),
onSuccess: (data) => {
setAuth(data.token, data.display_name, username)
navigate('/')
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
loginMutation.mutate()
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="max-w-md w-full mx-4">
<div className="bg-white rounded-lg shadow-lg p-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">
Task Reporter
</h1>
<p className="text-gray-600 mt-2">
Production Line Incident Response System
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Username */}
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="Enter your email"
required
autoComplete="username"
/>
</div>
{/* Password */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="Enter your password"
required
autoComplete="current-password"
/>
</div>
{/* Error Message */}
{loginMutation.isError && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{loginMutation.error instanceof Error
? 'Invalid credentials. Please try again.'
: 'An error occurred. Please try again.'}
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg font-medium hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loginMutation.isPending ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Logging in...
</span>
) : (
'Login'
)}
</button>
</form>
{/* Footer */}
<p className="text-center text-gray-500 text-sm mt-6">
Use your company credentials to login
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Link } from 'react-router'
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-gray-300 mb-4">404</h1>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">Page Not Found</h2>
<p className="text-gray-600 mb-6">
The page you're looking for doesn't exist or has been moved.
</p>
<Link
to="/"
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Go to Home
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,852 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, Link } from 'react-router'
import {
useRoom,
useRoomPermissions,
useUpdateRoom,
useAddMember,
useUpdateMemberRole,
useRemoveMember,
} from '../hooks/useRooms'
import { useMessages } from '../hooks/useMessages'
import { useWebSocket } from '../hooks/useWebSocket'
import { useFiles, useUploadFile, useDeleteFile } from '../hooks/useFiles'
import { filesService } from '../services/files'
import { useChatStore } from '../stores/chatStore'
import { useAuthStore } from '../stores/authStore'
import { Breadcrumb } from '../components/common'
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata } from '../types'
const statusColors: Record<RoomStatus, string> = {
active: 'bg-green-100 text-green-800',
resolved: 'bg-blue-100 text-blue-800',
archived: 'bg-gray-100 text-gray-800',
}
const severityColors: Record<SeverityLevel, string> = {
low: 'bg-gray-100 text-gray-800',
medium: 'bg-yellow-100 text-yellow-800',
high: 'bg-orange-100 text-orange-800',
critical: 'bg-red-100 text-red-800',
}
const roleLabels: Record<MemberRole, string> = {
owner: 'Owner',
editor: 'Editor',
viewer: 'Viewer',
}
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉']
export default function RoomDetail() {
const { roomId } = useParams<{ roomId: string }>()
const user = useAuthStore((state) => state.user)
const { data: room, isLoading: roomLoading, error: roomError } = useRoom(roomId || '')
const { data: permissions } = useRoomPermissions(roomId || '')
const { data: messagesData, isLoading: messagesLoading } = useMessages(roomId || '', { limit: 50 })
const { messages, connectionStatus, typingUsers, onlineUsers, setMessages, setCurrentRoom } = useChatStore()
const { sendTextMessage, sendTyping, editMessage, deleteMessage, addReaction, removeReaction } = useWebSocket(roomId || null)
// Mutations
const updateRoom = useUpdateRoom(roomId || '')
const addMember = useAddMember(roomId || '')
const updateMemberRole = useUpdateMemberRole(roomId || '')
const removeMember = useRemoveMember(roomId || '')
// File hooks
const { data: filesData, isLoading: filesLoading } = useFiles(roomId || '')
const uploadFile = useUploadFile(roomId || '')
const deleteFile = useDeleteFile(roomId || '')
const [messageInput, setMessageInput] = useState('')
const [showMembers, setShowMembers] = useState(false)
const [showFiles, setShowFiles] = useState(false)
const [showAddMember, setShowAddMember] = useState(false)
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
const [editContent, setEditContent] = useState('')
const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null)
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [previewFile, setPreviewFile] = useState<FileMetadata | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [newMemberUsername, setNewMemberUsername] = useState('')
const [newMemberRole, setNewMemberRole] = useState<MemberRole>('viewer')
const messagesEndRef = useRef<HTMLDivElement>(null)
const typingTimeoutRef = useRef<number | null>(null)
// Initialize room
useEffect(() => {
if (roomId) {
setCurrentRoom(roomId)
}
return () => {
setCurrentRoom(null)
}
}, [roomId, setCurrentRoom])
// Load initial messages
useEffect(() => {
if (messagesData?.messages) {
setMessages(messagesData.messages)
}
}, [messagesData, setMessages])
// Auto-scroll to bottom
useEffect(() => {
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
sendTextMessage(messageInput.trim())
setMessageInput('')
sendTyping(false)
}
const handleStartEdit = (messageId: string, content: string) => {
setEditingMessageId(messageId)
setEditContent(content)
}
const handleCancelEdit = () => {
setEditingMessageId(null)
setEditContent('')
}
const handleSaveEdit = () => {
if (!editingMessageId || !editContent.trim()) return
editMessage(editingMessageId, editContent.trim())
setEditingMessageId(null)
setEditContent('')
}
const handleDeleteMessage = (messageId: string) => {
if (window.confirm('Are you sure you want to delete this message?')) {
deleteMessage(messageId)
}
}
const handleAddReaction = (messageId: string, emoji: string) => {
addReaction(messageId, emoji)
setShowEmojiPickerFor(null)
}
const handleRemoveReaction = (messageId: string, emoji: string) => {
removeReaction(messageId, emoji)
}
const handleStatusChange = (newStatus: RoomStatus) => {
if (window.confirm(`Are you sure you want to change the room status to "${newStatus}"?`)) {
updateRoom.mutate({ status: newStatus })
}
}
const handleAddMember = (e: React.FormEvent) => {
e.preventDefault()
if (!newMemberUsername.trim()) return
addMember.mutate(
{ userId: newMemberUsername.trim(), role: newMemberRole },
{
onSuccess: () => {
setNewMemberUsername('')
setNewMemberRole('viewer')
setShowAddMember(false)
},
}
)
}
const handleRoleChange = (userId: string, newRole: MemberRole) => {
updateMemberRole.mutate({ userId, role: newRole })
}
const handleRemoveMember = (userId: string) => {
if (window.confirm(`Are you sure you want to remove this member?`)) {
removeMember.mutate(userId)
}
}
// File handlers
const handleFileUpload = useCallback(
(files: FileList | null) => {
if (!files || files.length === 0) return
const file = files[0]
setUploadProgress(0)
uploadFile.mutate(
{
file,
onProgress: (progress) => setUploadProgress(progress),
},
{
onSuccess: () => {
setUploadProgress(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
},
onError: () => {
setUploadProgress(null)
},
}
)
},
[uploadFile]
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
handleFileUpload(e.dataTransfer.files)
},
[handleFileUpload]
)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDeleteFile = (fileId: string) => {
if (window.confirm('Are you sure you want to delete this file?')) {
deleteFile.mutate(fileId)
}
}
const handleDownloadFile = async (file: FileMetadata) => {
if (file.download_url) {
window.open(file.download_url, '_blank')
} else if (roomId) {
await filesService.downloadFile(roomId, file.file_id)
}
}
if (roomLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)
}
if (roomError || !room) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-500 mb-4">Failed to load room</p>
<Link to="/" className="text-blue-600 hover:text-blue-700">
Back to Room List
</Link>
</div>
</div>
)
}
const typingUsersArray = Array.from(typingUsers).filter((u) => u !== user?.username)
const onlineUsersArray = Array.from(onlineUsers)
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Header */}
<header className="bg-white shadow-sm flex-shrink-0">
<div className="max-w-7xl mx-auto px-4 py-3">
{/* Breadcrumb */}
<div className="mb-2">
<Breadcrumb
items={[
{ label: 'Home', href: '/' },
{ label: 'Rooms', href: '/' },
{ label: room.title },
]}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div>
<h1 className="font-semibold text-gray-900">{room.title}</h1>
<div className="flex items-center gap-2 text-sm">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColors[room.status]}`}>
{room.status}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${severityColors[room.severity]}`}>
{room.severity}
</span>
{room.location && <span className="text-gray-500">{room.location}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-4">
{/* Connection Status */}
<div className="flex items-center gap-1">
<div
className={`w-2 h-2 rounded-full ${
connectionStatus === 'connected'
? 'bg-green-500'
: connectionStatus === 'connecting'
? 'bg-yellow-500'
: 'bg-red-500'
}`}
/>
<span className="text-xs text-gray-500">
{connectionStatus === 'connected' ? 'Connected' : connectionStatus}
</span>
</div>
{/* Status Actions (Owner only) */}
{permissions?.can_update_status && room.status === 'active' && (
<div className="flex items-center gap-2">
<button
onClick={() => handleStatusChange('resolved')}
className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
Resolve
</button>
<button
onClick={() => handleStatusChange('archived')}
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Archive
</button>
</div>
)}
{/* Files Toggle */}
<button
onClick={() => {
setShowFiles(!showFiles)
setShowMembers(false)
}}
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={() => {
setShowMembers(!showMembers)
setShowFiles(false)
}}
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>
</header>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
{/* Chat Area */}
<div className="flex-1 flex flex-col">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messagesLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
</div>
) : messages.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No messages yet. Start the conversation!
</div>
) : (
messages.map((message) => {
const isOwnMessage = message.sender_id === user?.username
const isEditing = editingMessageId === message.message_id
return (
<div
key={message.message_id}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} group`}
>
<div
className={`max-w-[70%] rounded-lg px-4 py-2 ${
isOwnMessage
? 'bg-blue-600 text-white'
: 'bg-white shadow-sm'
}`}
>
{!isOwnMessage && (
<div className="text-xs font-medium text-gray-500 mb-1">
{message.sender_id}
</div>
)}
{isEditing ? (
<div className="space-y-2">
<input
type="text"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full px-2 py-1 text-sm text-gray-900 bg-white border border-gray-300 rounded"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit()
if (e.key === 'Escape') handleCancelEdit()
}}
/>
<div className="flex gap-2 text-xs">
<button
onClick={handleSaveEdit}
className="px-2 py-1 bg-green-500 text-white rounded hover:bg-green-600"
>
Save
</button>
<button
onClick={handleCancelEdit}
className="px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
>
Cancel
</button>
</div>
</div>
) : (
<>
<p className={isOwnMessage ? 'text-white' : 'text-gray-900'}>
{message.content}
</p>
{/* Reactions Display */}
{message.reaction_counts && Object.keys(message.reaction_counts).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{Object.entries(message.reaction_counts).map(([emoji, count]) => (
<button
key={emoji}
onClick={() => handleRemoveReaction(message.message_id, emoji)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs ${
isOwnMessage
? 'bg-blue-500 hover:bg-blue-400'
: 'bg-gray-100 hover:bg-gray-200'
}`}
title={`Remove ${emoji}`}
>
<span>{emoji}</span>
<span className={isOwnMessage ? 'text-blue-100' : 'text-gray-600'}>{count}</span>
</button>
))}
</div>
)}
<div className="flex items-center justify-between gap-2">
<div
className={`text-xs mt-1 ${
isOwnMessage ? 'text-blue-200' : 'text-gray-400'
}`}
>
{new Date(message.created_at).toLocaleTimeString()}
{message.edited_at && ' (edited)'}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{/* Reaction Button */}
<div className="relative">
<button
onClick={() => setShowEmojiPickerFor(
showEmojiPickerFor === message.message_id ? null : message.message_id
)}
className={`p-1 ${isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-gray-600'}`}
title="Add reaction"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
{/* Emoji Picker Dropdown */}
{showEmojiPickerFor === message.message_id && (
<div className={`absolute bottom-full mb-1 ${isOwnMessage ? 'right-0' : 'left-0'} bg-white shadow-lg rounded-lg p-2 z-10 flex gap-1`}>
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
onClick={() => handleAddReaction(message.message_id, emoji)}
className="w-7 h-7 flex items-center justify-center hover:bg-gray-100 rounded"
>
{emoji}
</button>
))}
</div>
)}
</div>
{/* Edit/Delete (own messages only) */}
{isOwnMessage && (
<>
<button
onClick={() => handleStartEdit(message.message_id, message.content)}
className="p-1 text-blue-200 hover:text-white"
title="Edit"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDeleteMessage(message.message_id)}
className="p-1 text-blue-200 hover:text-red-300"
title="Delete"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</>
)}
</div>
</div>
</>
)}
</div>
</div>
)
})
)}
<div ref={messagesEndRef} />
</div>
{/* Typing Indicator */}
{typingUsersArray.length > 0 && (
<div className="px-4 py-2 text-sm text-gray-500">
{typingUsersArray.join(', ')} {typingUsersArray.length === 1 ? 'is' : 'are'} typing...
</div>
)}
{/* Message Input */}
{permissions?.can_write && (
<form onSubmit={handleSendMessage} className="p-4 bg-white border-t">
<div className="flex gap-2">
<input
type="text"
value={messageInput}
onChange={handleInputChange}
placeholder="Type a message..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
<button
type="submit"
disabled={!messageInput.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</div>
</form>
)}
</div>
{/* Members Sidebar */}
{showMembers && (
<div className="w-72 bg-white border-l flex-shrink-0 overflow-y-auto">
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900">Members</h3>
{permissions?.can_manage_members && (
<button
onClick={() => setShowAddMember(!showAddMember)}
className="text-blue-600 hover:text-blue-700 text-sm"
>
+ Add
</button>
)}
</div>
{/* Add Member Form */}
{showAddMember && (
<form onSubmit={handleAddMember} className="mb-4 p-3 bg-gray-50 rounded-lg">
<input
type="text"
value={newMemberUsername}
onChange={(e) => setNewMemberUsername(e.target.value)}
placeholder="Username"
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded mb-2"
/>
<div className="flex gap-2">
<select
value={newMemberRole}
onChange={(e) => setNewMemberRole(e.target.value as MemberRole)}
className="flex-1 px-2 py-1.5 text-sm border border-gray-300 rounded"
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
</select>
<button
type="submit"
disabled={addMember.isPending}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
</div>
{addMember.isError && (
<p className="text-xs text-red-500 mt-1">Failed to add member</p>
)}
</form>
)}
{/* Member List */}
<div className="space-y-2">
{room.members?.map((member) => (
<div
key={member.user_id}
className="flex items-center justify-between py-2 px-2 hover:bg-gray-50 rounded"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className={`w-2 h-2 rounded-full flex-shrink-0 ${
onlineUsersArray.includes(member.user_id) ? 'bg-green-500' : 'bg-gray-300'
}`}
/>
<span className="text-sm text-gray-900 truncate">{member.user_id}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Role selector (Owner can change roles except their own) */}
{permissions?.role === 'owner' && member.role !== 'owner' ? (
<select
value={member.role}
onChange={(e) => handleRoleChange(member.user_id, e.target.value as MemberRole)}
className="text-xs px-1 py-0.5 border border-gray-200 rounded"
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
</select>
) : (
<span className="text-xs text-gray-500 px-1">{roleLabels[member.role]}</span>
)}
{/* Remove button (Owner/Editor can remove, but not the owner) */}
{permissions?.can_manage_members &&
member.role !== 'owner' &&
member.user_id !== user?.username && (
<button
onClick={() => handleRemoveMember(member.user_id)}
className="p-1 text-gray-400 hover:text-red-500"
title="Remove member"
>
<svg className="w-4 h-4" 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>
</div>
))}
</div>
</div>
</div>
)}
{/* Files Sidebar */}
{showFiles && (
<div className="w-80 bg-white border-l flex-shrink-0 overflow-y-auto">
<div className="p-4">
<h3 className="font-semibold text-gray-900 mb-4">Files</h3>
{/* Upload Area */}
{permissions?.can_write && (
<div
className={`mb-4 border-2 border-dashed rounded-lg p-4 text-center transition-colors ${
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<input
type="file"
ref={fileInputRef}
onChange={(e) => handleFileUpload(e.target.files)}
className="hidden"
/>
{uploadProgress !== null ? (
<div>
<div className="text-sm text-gray-600 mb-2">Uploading...</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1">{uploadProgress}%</div>
</div>
) : (
<>
<svg className="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-sm text-gray-600">
Drag & drop or{' '}
<button
onClick={() => fileInputRef.current?.click()}
className="text-blue-600 hover:text-blue-700"
>
browse
</button>
</p>
</>
)}
</div>
)}
{/* File List */}
{filesLoading ? (
<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 ? (
<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) => (
<div
key={file.file_id}
className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded"
>
{/* Thumbnail or Icon */}
<div className="w-10 h-10 flex-shrink-0 rounded bg-gray-100 flex items-center justify-center overflow-hidden">
{filesService.isImage(file.mime_type) ? (
<svg className="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
) : (
<svg className="w-5 h-5 text-gray-400" 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>
)}
</div>
{/* File Info */}
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900 truncate">{file.filename}</p>
<p className="text-xs text-gray-500">
{filesService.formatFileSize(file.file_size)}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
{/* Preview button for images */}
{filesService.isImage(file.mime_type) && (
<button
onClick={() => setPreviewFile(file)}
className="p-1 text-gray-400 hover:text-blue-500"
title="Preview"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
)}
{/* Download button */}
<button
onClick={() => handleDownloadFile(file)}
className="p-1 text-gray-400 hover:text-blue-500"
title="Download"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
{/* Delete button */}
{(file.uploader_id === user?.username || permissions?.can_delete) && (
<button
onClick={() => handleDeleteFile(file.file_id)}
className="p-1 text-gray-400 hover:text-red-500"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
{/* Image Preview Modal */}
{previewFile && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
onClick={() => setPreviewFile(null)}
>
<div className="relative max-w-4xl max-h-[90vh] m-4">
<button
onClick={() => setPreviewFile(null)}
className="absolute -top-10 right-0 text-white hover:text-gray-300"
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<img
src={previewFile.download_url || ''}
alt={previewFile.filename}
className="max-w-full max-h-[85vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
<div className="absolute -bottom-10 left-0 right-0 flex justify-center gap-4">
<span className="text-white text-sm">{previewFile.filename}</span>
<button
onClick={(e) => {
e.stopPropagation()
handleDownloadFile(previewFile)
}}
className="text-white hover:text-blue-400 text-sm"
>
Download
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,376 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { useRooms, useCreateRoom, useRoomTemplates } from '../hooks/useRooms'
import { useAuthStore } from '../stores/authStore'
import { Breadcrumb } from '../components/common'
import type { RoomStatus, IncidentType, SeverityLevel, CreateRoomRequest } from '../types'
const statusColors: Record<RoomStatus, string> = {
active: 'bg-green-100 text-green-800',
resolved: 'bg-blue-100 text-blue-800',
archived: 'bg-gray-100 text-gray-800',
}
const severityColors: Record<SeverityLevel, string> = {
low: 'bg-gray-100 text-gray-800',
medium: 'bg-yellow-100 text-yellow-800',
high: 'bg-orange-100 text-orange-800',
critical: 'bg-red-100 text-red-800',
}
const incidentTypeLabels: Record<IncidentType, string> = {
equipment_failure: 'Equipment Failure',
material_shortage: 'Material Shortage',
quality_issue: 'Quality Issue',
other: 'Other',
}
const ITEMS_PER_PAGE = 12
export default function RoomList() {
const user = useAuthStore((state) => state.user)
const clearAuth = useAuthStore((state) => state.clearAuth)
const [statusFilter, setStatusFilter] = useState<RoomStatus | ''>('')
const [search, setSearch] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false)
const [page, setPage] = useState(1)
// Reset page when filters change
const handleStatusChange = (status: RoomStatus | '') => {
setStatusFilter(status)
setPage(1)
}
const handleSearchChange = (searchValue: string) => {
setSearch(searchValue)
setPage(1)
}
const { data, isLoading, error } = useRooms({
status: statusFilter || undefined,
search: search || undefined,
limit: ITEMS_PER_PAGE,
offset: (page - 1) * ITEMS_PER_PAGE,
})
const totalPages = data ? Math.ceil(data.total / ITEMS_PER_PAGE) : 0
const handleLogout = () => {
clearAuth()
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-xl font-bold text-gray-900">Task Reporter</h1>
<div className="flex items-center gap-4">
<span className="text-gray-600">{user?.display_name}</span>
<button
onClick={handleLogout}
className="text-gray-500 hover:text-gray-700"
>
Logout
</button>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 py-6">
{/* Breadcrumb */}
<div className="mb-4">
<Breadcrumb items={[{ label: 'Home', href: '/' }, { label: 'Rooms' }]} />
</div>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
{/* Search */}
<div className="flex-1">
<input
type="text"
placeholder="Search rooms..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
</div>
{/* Status Filter */}
<select
value={statusFilter}
onChange={(e) => handleStatusChange(e.target.value as RoomStatus | '')}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="resolved">Resolved</option>
<option value="archived">Archived</option>
</select>
{/* New Room Button */}
<button
onClick={() => setShowCreateModal(true)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
+ New Room
</button>
</div>
{/* Room List */}
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-gray-500">Loading rooms...</p>
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-red-500">Failed to load rooms</p>
</div>
) : data?.rooms.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500">No rooms found</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 text-blue-600 hover:text-blue-700"
>
Create your first room
</button>
</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data?.rooms.map((room) => (
<Link
key={room.room_id}
to={`/rooms/${room.room_id}`}
className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow"
>
{/* Room Header */}
<div className="flex justify-between items-start mb-2">
<h3 className="font-semibold text-gray-900 truncate flex-1">
{room.title}
</h3>
<span
className={`ml-2 px-2 py-0.5 rounded text-xs font-medium ${
statusColors[room.status]
}`}
>
{room.status}
</span>
</div>
{/* Type and Severity */}
<div className="flex gap-2 mb-3">
<span className="text-xs text-gray-500">
{incidentTypeLabels[room.incident_type]}
</span>
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
severityColors[room.severity]
}`}
>
{room.severity}
</span>
</div>
{/* Description */}
{room.description && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{room.description}
</p>
)}
{/* Footer */}
<div className="flex justify-between items-center text-xs text-gray-400">
<span>{room.member_count} members</span>
<span>
{new Date(room.last_activity_at).toLocaleDateString()}
</span>
</div>
</Link>
))}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-gray-600">
Page {page} of {totalPages}
<span className="text-gray-400 ml-2">
({data?.total} total)
</span>
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</>
)}
</main>
{/* Create Room Modal */}
{showCreateModal && (
<CreateRoomModal onClose={() => setShowCreateModal(false)} />
)}
</div>
)
}
// Create Room Modal Component
function CreateRoomModal({ onClose }: { onClose: () => void }) {
const [title, setTitle] = useState('')
const [incidentType, setIncidentType] = useState<IncidentType>('equipment_failure')
const [severity, setSeverity] = useState<SeverityLevel>('medium')
const [description, setDescription] = useState('')
const [location, setLocation] = useState('')
const createRoom = useCreateRoom()
// Templates loaded for future use (template selection feature)
useRoomTemplates()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const data: CreateRoomRequest = {
title,
incident_type: incidentType,
severity,
description: description || undefined,
location: location || undefined,
}
createRoom.mutate(data, {
onSuccess: () => {
onClose()
},
})
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-semibold mb-4">Create New Room</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Title *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
required
/>
</div>
{/* Incident Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Incident Type *
</label>
<select
value={incidentType}
onChange={(e) => setIncidentType(e.target.value as IncidentType)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
>
<option value="equipment_failure">Equipment Failure</option>
<option value="material_shortage">Material Shortage</option>
<option value="quality_issue">Quality Issue</option>
<option value="other">Other</option>
</select>
</div>
{/* Severity */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Severity *
</label>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value as SeverityLevel)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
{/* Location */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Location
</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="w-full 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., Line A, Station 3"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
rows={3}
placeholder="Describe the incident..."
/>
</div>
{/* Error */}
{createRoom.isError && (
<div className="bg-red-50 text-red-600 px-3 py-2 rounded-lg text-sm">
Failed to create room. Please try again.
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
type="submit"
disabled={createRoom.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{createRoom.isPending ? 'Creating...' : 'Create Room'}
</button>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { default as Login } from './Login'
export { default as RoomList } from './RoomList'
export { default as RoomDetail } from './RoomDetail'
export { default as NotFound } from './NotFound'

View File

@@ -0,0 +1,46 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
const API_BASE_URL = '/api'
// Create axios instance
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor - add auth token
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
// Response interceptor - handle errors
api.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
// Token expired or invalid - clear storage and redirect to login
localStorage.removeItem('token')
localStorage.removeItem('user')
// Only redirect if not already on login page
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export default api

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { authService } from './auth'
import api from './api'
// Mock the api module
vi.mock('./api', () => ({
default: {
post: vi.fn(),
get: vi.fn(),
},
}))
describe('authService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('login', () => {
it('should call api.post with correct params', async () => {
const mockResponse = { data: { token: 'test-token', display_name: 'Test User' } }
vi.mocked(api.post).mockResolvedValue(mockResponse)
const credentials = { username: 'testuser', password: 'password123' }
const result = await authService.login(credentials)
expect(api.post).toHaveBeenCalledWith('/auth/login', credentials)
expect(result).toEqual(mockResponse.data)
})
it('should throw error on failed login', async () => {
vi.mocked(api.post).mockRejectedValue(new Error('Invalid credentials'))
await expect(
authService.login({ username: 'testuser', password: 'wrongpass' })
).rejects.toThrow('Invalid credentials')
})
})
describe('logout', () => {
it('should call api.post to logout endpoint', async () => {
vi.mocked(api.post).mockResolvedValue({ data: {} })
await authService.logout()
expect(api.post).toHaveBeenCalledWith('/auth/logout')
})
})
describe('getToken', () => {
it('should return token from localStorage', () => {
vi.mocked(localStorage.getItem).mockReturnValue('stored-token')
const token = authService.getToken()
expect(localStorage.getItem).toHaveBeenCalledWith('token')
expect(token).toBe('stored-token')
})
it('should return null if no token stored', () => {
vi.mocked(localStorage.getItem).mockReturnValue(null)
const token = authService.getToken()
expect(token).toBeNull()
})
})
describe('setAuthData', () => {
it('should store token and user in localStorage', () => {
authService.setAuthData('new-token', 'Test User')
expect(localStorage.setItem).toHaveBeenCalledWith('token', 'new-token')
expect(localStorage.setItem).toHaveBeenCalledWith(
'user',
JSON.stringify({ display_name: 'Test User' })
)
})
})
describe('isAuthenticated', () => {
it('should return true if token exists', () => {
vi.mocked(localStorage.getItem).mockReturnValue('test-token')
expect(authService.isAuthenticated()).toBe(true)
})
it('should return false if no token', () => {
vi.mocked(localStorage.getItem).mockReturnValue(null)
expect(authService.isAuthenticated()).toBe(false)
})
})
})

View File

@@ -0,0 +1,62 @@
import api from './api'
import type { LoginRequest, LoginResponse } from '../types'
export const authService = {
/**
* Login with username and password
*/
async login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/auth/login', credentials)
return response.data
},
/**
* Logout current user
*/
async logout(): Promise<void> {
try {
await api.post('/auth/logout')
} finally {
// Always clear local storage even if API call fails
localStorage.removeItem('token')
localStorage.removeItem('user')
}
},
/**
* Get stored token
*/
getToken(): string | null {
return localStorage.getItem('token')
},
/**
* Store auth data
*/
setAuthData(token: string, displayName: string): void {
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify({ display_name: displayName }))
},
/**
* Get stored user data
*/
getUser(): { display_name: string } | null {
const userData = localStorage.getItem('user')
if (userData) {
try {
return JSON.parse(userData)
} catch {
return null
}
}
return null
},
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
return !!this.getToken()
},
}

View File

@@ -0,0 +1,114 @@
import api from './api'
import type {
FileMetadata,
FileListResponse,
FileUploadResponse,
FileType,
} from '../types'
export interface FileFilters {
file_type?: FileType
limit?: number
offset?: number
}
export const filesService = {
/**
* Upload file to room
*/
async uploadFile(
roomId: string,
file: File,
description?: string,
onProgress?: (progress: number) => void
): Promise<FileUploadResponse> {
const formData = new FormData()
formData.append('file', file)
if (description) {
formData.append('description', description)
}
const response = await api.post<FileUploadResponse>(
`/rooms/${roomId}/files`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(progress)
}
},
}
)
return response.data
},
/**
* List files in a room
*/
async listFiles(roomId: string, filters?: FileFilters): Promise<FileListResponse> {
const params = new URLSearchParams()
if (filters) {
if (filters.file_type) params.append('file_type', filters.file_type)
if (filters.limit) params.append('limit', filters.limit.toString())
if (filters.offset) params.append('offset', filters.offset.toString())
}
const response = await api.get<FileListResponse>(
`/rooms/${roomId}/files?${params.toString()}`
)
return response.data
},
/**
* Get file metadata with download URL
*/
async getFile(roomId: string, fileId: string): Promise<FileMetadata> {
const response = await api.get<FileMetadata>(`/rooms/${roomId}/files/${fileId}`)
return response.data
},
/**
* Delete file
*/
async deleteFile(roomId: string, fileId: string): Promise<void> {
await api.delete(`/rooms/${roomId}/files/${fileId}`)
},
/**
* Download file (opens in new tab or triggers download)
*/
async downloadFile(roomId: string, fileId: string): Promise<void> {
const fileData = await this.getFile(roomId, fileId)
if (fileData.download_url) {
window.open(fileData.download_url, '_blank')
}
},
/**
* Get file size as human readable string
*/
formatFileSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB']
let unitIndex = 0
let size = bytes
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
},
/**
* Check if file is an image
*/
isImage(mimeType: string): boolean {
return mimeType.startsWith('image/')
},
}

View File

@@ -0,0 +1,9 @@
export { default as api } from './api'
export { authService } from './auth'
export { roomsService } from './rooms'
export { messagesService } from './messages'
export { filesService } from './files'
export type { RoomFilters } from './rooms'
export type { MessageFilters } from './messages'
export type { FileFilters } from './files'

View File

@@ -0,0 +1,77 @@
import api from './api'
import type {
Message,
MessageListResponse,
CreateMessageRequest,
} from '../types'
export interface MessageFilters {
limit?: number
offset?: number
before?: string // ISO datetime
}
export const messagesService = {
/**
* Get messages for a room
*/
async getMessages(roomId: string, filters?: MessageFilters): Promise<MessageListResponse> {
const params = new URLSearchParams()
if (filters) {
if (filters.limit) params.append('limit', filters.limit.toString())
if (filters.offset) params.append('offset', filters.offset.toString())
if (filters.before) params.append('before', filters.before)
}
const response = await api.get<MessageListResponse>(
`/rooms/${roomId}/messages?${params.toString()}`
)
return response.data
},
/**
* Create message via REST API
*/
async createMessage(roomId: string, data: CreateMessageRequest): Promise<Message> {
const response = await api.post<Message>(`/rooms/${roomId}/messages`, data)
return response.data
},
/**
* Search messages in a room
*/
async searchMessages(
roomId: string,
query: string,
limit = 50,
offset = 0
): Promise<MessageListResponse> {
const params = new URLSearchParams({
q: query,
limit: limit.toString(),
offset: offset.toString(),
})
const response = await api.get<MessageListResponse>(
`/rooms/${roomId}/messages/search?${params.toString()}`
)
return response.data
},
/**
* Get online users in a room
*/
async getOnlineUsers(roomId: string): Promise<{ room_id: string; online_users: string[]; count: number }> {
const response = await api.get(`/rooms/${roomId}/online`)
return response.data
},
/**
* Get typing users in a room
*/
async getTypingUsers(roomId: string): Promise<{ room_id: string; typing_users: string[]; count: number }> {
const response = await api.get(`/rooms/${roomId}/typing`)
return response.data
},
}

View File

@@ -0,0 +1,136 @@
import api from './api'
import type {
Room,
RoomListResponse,
CreateRoomRequest,
UpdateRoomRequest,
RoomMember,
RoomTemplate,
PermissionResponse,
MemberRole,
RoomStatus,
IncidentType,
SeverityLevel,
} from '../types'
export interface RoomFilters {
status?: RoomStatus
incident_type?: IncidentType
severity?: SeverityLevel
search?: string
all?: boolean
limit?: number
offset?: number
}
export const roomsService = {
/**
* Get list of rooms
*/
async listRooms(filters?: RoomFilters): Promise<RoomListResponse> {
const params = new URLSearchParams()
if (filters) {
if (filters.status) params.append('status', filters.status)
if (filters.incident_type) params.append('incident_type', filters.incident_type)
if (filters.severity) params.append('severity', filters.severity)
if (filters.search) params.append('search', filters.search)
if (filters.all) params.append('all', 'true')
if (filters.limit) params.append('limit', filters.limit.toString())
if (filters.offset) params.append('offset', filters.offset.toString())
}
const response = await api.get<RoomListResponse>(`/rooms?${params.toString()}`)
return response.data
},
/**
* Get single room details
*/
async getRoom(roomId: string): Promise<Room> {
const response = await api.get<Room>(`/rooms/${roomId}`)
return response.data
},
/**
* Create new room
*/
async createRoom(data: CreateRoomRequest): Promise<Room> {
const response = await api.post<Room>('/rooms', data)
return response.data
},
/**
* Update room
*/
async updateRoom(roomId: string, data: UpdateRoomRequest): Promise<Room> {
const response = await api.patch<Room>(`/rooms/${roomId}`, data)
return response.data
},
/**
* Delete (archive) room
*/
async deleteRoom(roomId: string): Promise<void> {
await api.delete(`/rooms/${roomId}`)
},
/**
* Get room members
*/
async getMembers(roomId: string): Promise<RoomMember[]> {
const response = await api.get<RoomMember[]>(`/rooms/${roomId}/members`)
return response.data
},
/**
* Add member to room
*/
async addMember(roomId: string, userId: string, role: MemberRole): Promise<RoomMember> {
const response = await api.post<RoomMember>(`/rooms/${roomId}/members`, {
user_id: userId,
role,
})
return response.data
},
/**
* Update member role
*/
async updateMemberRole(roomId: string, userId: string, role: MemberRole): Promise<RoomMember> {
const response = await api.patch<RoomMember>(`/rooms/${roomId}/members/${userId}`, { role })
return response.data
},
/**
* Remove member from room
*/
async removeMember(roomId: string, userId: string): Promise<void> {
await api.delete(`/rooms/${roomId}/members/${userId}`)
},
/**
* Transfer ownership
*/
async transferOwnership(roomId: string, newOwnerId: string): Promise<void> {
await api.post(`/rooms/${roomId}/transfer-ownership`, {
new_owner_id: newOwnerId,
})
},
/**
* Get user permissions in room
*/
async getPermissions(roomId: string): Promise<PermissionResponse> {
const response = await api.get<PermissionResponse>(`/rooms/${roomId}/permissions`)
return response.data
},
/**
* Get room templates
*/
async getTemplates(): Promise<RoomTemplate[]> {
const response = await api.get<RoomTemplate[]>('/rooms/templates')
return response.data
},
}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useAuthStore } from './authStore'
describe('authStore', () => {
beforeEach(() => {
// Reset store state before each test
useAuthStore.setState({
user: null,
token: null,
isAuthenticated: false,
})
})
describe('setAuth', () => {
it('should set user and token', () => {
useAuthStore.getState().setAuth('test-token-123', 'Test User', 'testuser')
const state = useAuthStore.getState()
expect(state.user).toEqual({ username: 'testuser', display_name: 'Test User' })
expect(state.token).toBe('test-token-123')
expect(state.isAuthenticated).toBe(true)
})
})
describe('clearAuth', () => {
it('should clear user and token', () => {
// First set some auth data
useAuthStore.setState({
user: { username: 'testuser', display_name: 'Test User' },
token: 'test-token-123',
isAuthenticated: true,
})
useAuthStore.getState().clearAuth()
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.token).toBeNull()
expect(state.isAuthenticated).toBe(false)
})
})
describe('updateUser', () => {
it('should update user properties', () => {
// First set some auth data
useAuthStore.setState({
user: { username: 'testuser', display_name: 'Test User' },
token: 'test-token-123',
isAuthenticated: true,
})
useAuthStore.getState().updateUser({ display_name: 'Updated Name' })
const state = useAuthStore.getState()
expect(state.user?.display_name).toBe('Updated Name')
expect(state.user?.username).toBe('testuser')
})
it('should not update if user is null', () => {
useAuthStore.getState().updateUser({ display_name: 'New Name' })
expect(useAuthStore.getState().user).toBeNull()
})
})
})

View File

@@ -0,0 +1,51 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types'
interface AuthState {
token: string | null
user: User | null
isAuthenticated: boolean
// Actions
setAuth: (token: string, displayName: string, username: string) => void
clearAuth: () => void
updateUser: (user: Partial<User>) => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
isAuthenticated: false,
setAuth: (token: string, displayName: string, username: string) =>
set({
token,
user: { username, display_name: displayName },
isAuthenticated: true,
}),
clearAuth: () =>
set({
token: null,
user: null,
isAuthenticated: false,
}),
updateUser: (userData: Partial<User>) =>
set((state) => ({
user: state.user ? { ...state.user, ...userData } : null,
})),
}),
{
name: 'auth-storage',
partialize: (state) => ({
token: state.token,
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
}
)
)

View File

@@ -0,0 +1,172 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useChatStore } from './chatStore'
import type { Message } from '../types'
describe('chatStore', () => {
const mockMessage: Message = {
message_id: 'msg-1',
room_id: 'room-1',
sender_id: 'user-1',
content: 'Hello, world!',
message_type: 'text',
created_at: '2024-01-01T00:00:00Z',
sequence_number: 1,
}
beforeEach(() => {
// Reset store state before each test
useChatStore.setState({
currentRoomId: null,
messages: [],
hasMoreMessages: true,
connectionStatus: 'disconnected',
typingUsers: new Set(),
onlineUsers: new Set(),
})
})
describe('setCurrentRoom', () => {
it('should set current room and clear messages', () => {
useChatStore.setState({ messages: [mockMessage] })
useChatStore.getState().setCurrentRoom('room-2')
const state = useChatStore.getState()
expect(state.currentRoomId).toBe('room-2')
expect(state.messages).toEqual([])
})
it('should clear typing and online users', () => {
useChatStore.setState({
typingUsers: new Set(['user-1']),
onlineUsers: new Set(['user-2']),
})
useChatStore.getState().setCurrentRoom('room-2')
const state = useChatStore.getState()
expect(state.typingUsers.size).toBe(0)
expect(state.onlineUsers.size).toBe(0)
})
})
describe('setMessages', () => {
it('should set messages array', () => {
const messages = [mockMessage, { ...mockMessage, message_id: 'msg-2' }]
useChatStore.getState().setMessages(messages)
expect(useChatStore.getState().messages).toEqual(messages)
})
})
describe('addMessage', () => {
it('should add a new message', () => {
useChatStore.getState().addMessage(mockMessage)
expect(useChatStore.getState().messages).toHaveLength(1)
expect(useChatStore.getState().messages[0]).toEqual(mockMessage)
})
it('should append to existing messages', () => {
useChatStore.getState().addMessage(mockMessage)
useChatStore.getState().addMessage({ ...mockMessage, message_id: 'msg-2' })
expect(useChatStore.getState().messages).toHaveLength(2)
})
})
describe('updateMessage', () => {
it('should update an existing message', () => {
useChatStore.getState().addMessage(mockMessage)
useChatStore.getState().updateMessage('msg-1', { content: 'Updated content' })
const updatedMessage = useChatStore.getState().messages[0]
expect(updatedMessage.content).toBe('Updated content')
})
it('should not update non-existent message', () => {
useChatStore.getState().addMessage(mockMessage)
useChatStore.getState().updateMessage('non-existent', { content: 'New content' })
expect(useChatStore.getState().messages[0].content).toBe('Hello, world!')
})
})
describe('removeMessage', () => {
it('should remove a message by id', () => {
useChatStore.getState().addMessage(mockMessage)
useChatStore.getState().removeMessage('msg-1')
expect(useChatStore.getState().messages).toHaveLength(0)
})
})
describe('setConnectionStatus', () => {
it('should update connection status', () => {
useChatStore.getState().setConnectionStatus('connected')
expect(useChatStore.getState().connectionStatus).toBe('connected')
})
})
describe('setUserTyping', () => {
it('should add user to typing set when typing', () => {
useChatStore.getState().setUserTyping('user-1', true)
expect(useChatStore.getState().typingUsers.has('user-1')).toBe(true)
})
it('should remove user from typing set when not typing', () => {
useChatStore.setState({ typingUsers: new Set(['user-1']) })
useChatStore.getState().setUserTyping('user-1', false)
expect(useChatStore.getState().typingUsers.has('user-1')).toBe(false)
})
})
describe('online users', () => {
it('should add online user', () => {
useChatStore.getState().addOnlineUser('user-1')
expect(useChatStore.getState().onlineUsers.has('user-1')).toBe(true)
})
it('should remove online user', () => {
useChatStore.setState({ onlineUsers: new Set(['user-1']) })
useChatStore.getState().removeOnlineUser('user-1')
expect(useChatStore.getState().onlineUsers.has('user-1')).toBe(false)
})
})
describe('prependMessages', () => {
it('should prepend messages to beginning of list', () => {
useChatStore.getState().addMessage(mockMessage)
const newMessage = { ...mockMessage, message_id: 'msg-0', sequence_number: 0 }
useChatStore.getState().prependMessages([newMessage])
const messages = useChatStore.getState().messages
expect(messages).toHaveLength(2)
expect(messages[0].message_id).toBe('msg-0')
expect(messages[1].message_id).toBe('msg-1')
})
})
describe('clearMessages', () => {
it('should clear all messages', () => {
useChatStore.getState().addMessage(mockMessage)
useChatStore.getState().addMessage({ ...mockMessage, message_id: 'msg-2' })
useChatStore.getState().clearMessages()
expect(useChatStore.getState().messages).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,125 @@
import { create } from 'zustand'
import type { Message } from '../types'
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
interface ChatState {
// Connection state
connectionStatus: ConnectionStatus
currentRoomId: string | null
// Messages
messages: Message[]
hasMoreMessages: boolean
// Typing indicators
typingUsers: Set<string>
// Online users
onlineUsers: Set<string>
// Actions
setConnectionStatus: (status: ConnectionStatus) => void
setCurrentRoom: (roomId: string | null) => void
// Message actions
setMessages: (messages: Message[]) => void
addMessage: (message: Message) => void
updateMessage: (messageId: string, updates: Partial<Message>) => void
removeMessage: (messageId: string) => void
prependMessages: (messages: Message[]) => void
setHasMoreMessages: (hasMore: boolean) => void
clearMessages: () => void
// Typing actions
setUserTyping: (userId: string, isTyping: boolean) => void
clearTypingUsers: () => void
// Online users actions
setOnlineUsers: (users: string[]) => void
addOnlineUser: (userId: string) => void
removeOnlineUser: (userId: string) => void
}
export const useChatStore = create<ChatState>((set) => ({
// Initial state
connectionStatus: 'disconnected',
currentRoomId: null,
messages: [],
hasMoreMessages: true,
typingUsers: new Set(),
onlineUsers: new Set(),
// Connection actions
setConnectionStatus: (status) => set({ connectionStatus: status }),
setCurrentRoom: (roomId) =>
set({
currentRoomId: roomId,
messages: [],
hasMoreMessages: true,
typingUsers: new Set(),
onlineUsers: new Set(),
connectionStatus: 'disconnected',
}),
// Message actions
setMessages: (messages) => set({ messages }),
addMessage: (message) =>
set((state) => ({
messages: [...state.messages, message],
})),
updateMessage: (messageId, updates) =>
set((state) => ({
messages: state.messages.map((msg) =>
msg.message_id === messageId ? { ...msg, ...updates } : msg
),
})),
removeMessage: (messageId) =>
set((state) => ({
messages: state.messages.filter((msg) => msg.message_id !== messageId),
})),
prependMessages: (newMessages) =>
set((state) => ({
messages: [...newMessages, ...state.messages],
})),
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
clearMessages: () => set({ messages: [], hasMoreMessages: true }),
// Typing actions
setUserTyping: (userId, isTyping) =>
set((state) => {
const newTypingUsers = new Set(state.typingUsers)
if (isTyping) {
newTypingUsers.add(userId)
} else {
newTypingUsers.delete(userId)
}
return { typingUsers: newTypingUsers }
}),
clearTypingUsers: () => set({ typingUsers: new Set() }),
// Online users actions
setOnlineUsers: (users) => set({ onlineUsers: new Set(users) }),
addOnlineUser: (userId) =>
set((state) => {
const newOnlineUsers = new Set(state.onlineUsers)
newOnlineUsers.add(userId)
return { onlineUsers: newOnlineUsers }
}),
removeOnlineUser: (userId) =>
set((state) => {
const newOnlineUsers = new Set(state.onlineUsers)
newOnlineUsers.delete(userId)
return { onlineUsers: newOnlineUsers }
}),
}))

View File

@@ -0,0 +1,2 @@
export { useAuthStore } from './authStore'
export { useChatStore } from './chatStore'

View File

@@ -0,0 +1,32 @@
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
// Cleanup after each test
afterEach(() => {
cleanup()
})
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})

View File

@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, type RenderOptions } from '@testing-library/react'
import { BrowserRouter } from 'react-router'
// Create a fresh QueryClient for each test
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: 0,
},
mutations: {
retry: false,
},
},
})
}
interface WrapperProps {
children: ReactNode
}
// Wrapper with all providers
export function createWrapper() {
const queryClient = createTestQueryClient()
return function Wrapper({ children }: WrapperProps) {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
{children}
</BrowserRouter>
</QueryClientProvider>
)
}
}
// Custom render function with providers
function customRender(
ui: React.ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, { wrapper: createWrapper(), ...options })
}
// Re-export everything
export * from '@testing-library/react'
export { customRender as render }

251
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,251 @@
// Authentication Types
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string
display_name: string
}
export interface User {
username: string
display_name: string
}
// Room Types
export type IncidentType = 'equipment_failure' | 'material_shortage' | 'quality_issue' | 'other'
export type SeverityLevel = 'low' | 'medium' | 'high' | 'critical'
export type RoomStatus = 'active' | 'resolved' | 'archived'
export type MemberRole = 'owner' | 'editor' | 'viewer'
export interface RoomMember {
user_id: string
role: MemberRole
added_by: string
added_at: string
removed_at?: string | null
}
export interface Room {
room_id: string
title: string
incident_type: IncidentType
severity: SeverityLevel
status: RoomStatus
location?: string | null
description?: string | null
resolution_notes?: string | null
created_by: string
created_at: string
resolved_at?: string | null
archived_at?: string | null
last_activity_at: string
last_updated_at: string
ownership_transferred_at?: string | null
ownership_transferred_by?: string | null
member_count: number
members?: RoomMember[]
current_user_role?: MemberRole | null
is_admin_view?: boolean
}
export interface CreateRoomRequest {
title: string
incident_type: IncidentType
severity: SeverityLevel
location?: string
description?: string
template?: string
}
export interface UpdateRoomRequest {
title?: string
severity?: SeverityLevel
status?: RoomStatus
location?: string
description?: string
resolution_notes?: string
}
export interface RoomListResponse {
rooms: Room[]
total: number
limit: number
offset: number
}
export interface RoomTemplate {
template_id: number
name: string
description?: string
incident_type: IncidentType
default_severity: SeverityLevel
default_members?: Record<string, unknown>[]
metadata_fields?: Record<string, unknown>
}
export interface PermissionResponse {
role: MemberRole | null
is_admin: boolean
can_read: boolean
can_write: boolean
can_manage_members: boolean
can_transfer_ownership: boolean
can_update_status: boolean
can_delete: boolean
}
// Message Types
export type MessageType = 'text' | 'image_ref' | 'file_ref' | 'system' | 'incident_data'
export interface Message {
message_id: string
room_id: string
sender_id: string
content: string
message_type: MessageType
metadata?: Record<string, unknown>
created_at: string
edited_at?: string | null
deleted_at?: string | null
sequence_number: number
reaction_counts?: Record<string, number>
}
export interface MessageListResponse {
messages: Message[]
total: number
limit: number
offset: number
has_more: boolean
}
export interface CreateMessageRequest {
content: string
message_type?: MessageType
metadata?: Record<string, unknown>
}
// File Types
export type FileType = 'image' | 'document' | 'log'
export interface FileMetadata {
file_id: string
room_id: string
filename: string
file_type: FileType
mime_type: string
file_size: number
minio_bucket: string
minio_object_path: string
uploaded_at: string
uploader_id: string
deleted_at?: string | null
download_url?: string
}
export interface FileUploadResponse {
file_id: string
filename: string
file_type: FileType
file_size: number
mime_type: string
download_url: string
uploaded_at: string
uploader_id: string
}
export interface FileListResponse {
files: FileMetadata[]
total: number
limit: number
offset: number
has_more: boolean
}
// WebSocket Message Types
export type WebSocketMessageType =
| 'message'
| 'edit_message'
| 'delete_message'
| 'add_reaction'
| 'remove_reaction'
| 'typing'
| 'system'
export type SystemEventType =
| 'user_joined'
| 'user_left'
| 'room_status_changed'
| 'member_added'
| 'member_removed'
| 'file_uploaded'
| 'file_deleted'
export interface WebSocketMessageIn {
type: WebSocketMessageType
content?: string
message_type?: MessageType
message_id?: string
emoji?: string
metadata?: Record<string, unknown>
is_typing?: boolean
}
export interface MessageBroadcast {
type: 'message' | 'edit_message'
message_id: string
room_id: string
sender_id: string
content: string
message_type: MessageType
metadata?: Record<string, unknown>
created_at: string
edited_at?: string | null
sequence_number: number
}
export interface SystemBroadcast {
type: 'system'
event: SystemEventType
user_id?: string
room_id?: string
timestamp: string
data?: Record<string, unknown>
}
export interface TypingBroadcast {
type: 'typing'
room_id: string
user_id: string
is_typing: boolean
}
export interface FileUploadedBroadcast {
type: 'file_uploaded'
file_id: string
room_id: string
uploader_id: string
filename: string
file_type: string
file_size: number
mime_type: string
download_url?: string
uploaded_at: string
}
export interface FileDeletedBroadcast {
type: 'file_deleted'
file_id: string
room_id: string
deleted_by: string
deleted_at: string
}
// API Error Type
export interface ApiError {
error: string
detail?: string
}