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:
183
frontend/src/pages/Login.test.tsx
Normal file
183
frontend/src/pages/Login.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
136
frontend/src/pages/Login.tsx
Normal file
136
frontend/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
frontend/src/pages/NotFound.tsx
Normal file
21
frontend/src/pages/NotFound.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
852
frontend/src/pages/RoomDetail.tsx
Normal file
852
frontend/src/pages/RoomDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
376
frontend/src/pages/RoomList.tsx
Normal file
376
frontend/src/pages/RoomList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
frontend/src/pages/index.ts
Normal file
4
frontend/src/pages/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user