diff --git a/src/App.tsx b/src/App.tsx index ae7218e..39e8886 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,11 +17,17 @@ import MonitorPage from '@/pages/MonitorPage' import ProposesPage from '@/pages/ProposesPage' import ProposeDetailPage from '@/pages/ProposeDetailPage' import UsersPage from '@/pages/UsersPage' +import SupportDetailPage from '@/pages/SupportDetailPage' +import MeetingDetailPage from '@/pages/MeetingDetailPage' import axios from 'axios' const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080 const WIZARD_BASE = `http://127.0.0.1:${WIZARD_PORT}` +const getApiBase = () => { + return localStorage.getItem('HF_BACKEND_BASE_URL') || import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8000' +} + type AppState = 'checking' | 'setup' | 'ready' export default function App() { @@ -33,6 +39,22 @@ export default function App() { }, []) const checkInitialized = async () => { + // First try the backend /config/status endpoint (reads from config volume directly) + try { + const res = await axios.get(`${getApiBase()}/config/status`, { timeout: 5000 }) + const cfg = res.data || {} + if (cfg.backend_url) { + localStorage.setItem('HF_BACKEND_BASE_URL', cfg.backend_url) + } + if (cfg.initialized === true) { + setAppState('ready') + return + } + } catch { + // Backend unreachable — fall through to wizard check + } + + // Fallback: try the wizard directly (needed during initial setup before backend starts) try { const res = await axios.get(`${WIZARD_BASE}/api/v1/config/harborforge.json`, { timeout: 5000, @@ -47,7 +69,7 @@ export default function App() { setAppState('setup') } } catch { - // Wizard unreachable or config doesn't exist → setup needed + // Neither backend nor wizard reachable → setup needed setAppState('setup') } } @@ -96,6 +118,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/CopyableCode.tsx b/src/components/CopyableCode.tsx new file mode 100644 index 0000000..a8d9d04 --- /dev/null +++ b/src/components/CopyableCode.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' + +interface Props { + code: string + prefix?: string +} + +export default function CopyableCode({ code, prefix }: Props) { + const [copied, setCopied] = useState(false) + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation() + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // fallback: select text + } + } + + return ( + + {prefix}{code} + {copied && ( + + )} + + ) +} diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index cbf9bab..0727349 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -55,13 +55,13 @@ export default function DashboardPage() {

Recent Tasks

- + {(stats.recent_tasks || []).map((i) => ( - - + + diff --git a/src/pages/MeetingDetailPage.tsx b/src/pages/MeetingDetailPage.tsx new file mode 100644 index 0000000..5af76eb --- /dev/null +++ b/src/pages/MeetingDetailPage.tsx @@ -0,0 +1,322 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import api from '@/services/api' +import { useAuth } from '@/hooks/useAuth' +import dayjs from 'dayjs' +import CopyableCode from '@/components/CopyableCode' + +interface MeetingItem { + id: number + code: string | null + meeting_code: string | null + title: string + description: string | null + status: string + priority: string + project_id: number + project_code: string | null + milestone_id: number + milestone_code: string | null + reporter_id: number + meeting_time: string | null + scheduled_at: string | null + duration_minutes: number | null + participants: string[] + created_at: string + updated_at: string | null +} + +const STATUS_OPTIONS = ['scheduled', 'in_progress', 'completed', 'cancelled'] + +export default function MeetingDetailPage() { + const { meetingId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const [meeting, setMeeting] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState('') + const [editMode, setEditMode] = useState(false) + const [editForm, setEditForm] = useState({ + title: '', + description: '', + meeting_time: '', + duration_minutes: '', + }) + + const fetchMeeting = async () => { + try { + const { data } = await api.get(`/meetings/${meetingId}`) + setMeeting(data) + setEditForm({ + title: data.title, + description: data.description || '', + meeting_time: data.meeting_time || data.scheduled_at || '', + duration_minutes: data.duration_minutes ? String(data.duration_minutes) : '', + }) + } catch (err: any) { + setMessage(err.response?.data?.detail || 'Failed to load meeting') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchMeeting() + }, [meetingId]) + + const handleAttend = async () => { + if (!meeting) return + setSaving(true) + setMessage('') + try { + const { data } = await api.post(`/meetings/${meetingId}/attend`) + setMeeting(data) + setMessage('You have joined this meeting') + } catch (err: any) { + setMessage(err.response?.data?.detail || 'Failed to attend meeting') + } finally { + setSaving(false) + } + } + + const handleTransition = async (newStatus: string) => { + if (!meeting) return + setSaving(true) + setMessage('') + try { + const { data } = await api.patch(`/meetings/${meetingId}`, { + status: newStatus, + }) + setMeeting(data) + setMessage(`Status changed to ${newStatus}`) + } catch (err: any) { + setMessage(err.response?.data?.detail || 'Failed to update meeting status') + } finally { + setSaving(false) + } + } + + const handleSave = async () => { + if (!meeting) return + setSaving(true) + setMessage('') + try { + const payload: Record = {} + if (editForm.title.trim() !== meeting.title) payload.title = editForm.title.trim() + if ((editForm.description || '') !== (meeting.description || '')) payload.description = editForm.description || null + const currentTime = meeting.meeting_time || meeting.scheduled_at || '' + if (editForm.meeting_time !== currentTime) payload.meeting_time = editForm.meeting_time || null + const currentDuration = meeting.duration_minutes ? String(meeting.duration_minutes) : '' + if (editForm.duration_minutes !== currentDuration) { + payload.duration_minutes = editForm.duration_minutes ? Number(editForm.duration_minutes) : null + } + + if (Object.keys(payload).length === 0) { + setEditMode(false) + return + } + + const { data } = await api.patch(`/meetings/${meetingId}`, payload) + setMeeting(data) + setEditMode(false) + setMessage('Meeting updated') + } catch (err: any) { + setMessage(err.response?.data?.detail || 'Failed to update meeting') + } finally { + setSaving(false) + } + } + + const handleDelete = async () => { + if (!meeting) return + if (!confirm(`Delete meeting ${meeting.meeting_code || meeting.id}? This cannot be undone.`)) return + setSaving(true) + try { + await api.delete(`/meetings/${meetingId}`) + navigate(-1) + } catch (err: any) { + setMessage(err.response?.data?.detail || 'Failed to delete meeting') + setSaving(false) + } + } + + if (loading) return
Loading meeting...
+ if (!meeting) return
{message || 'Meeting not found'}
+ + const isParticipant = user && meeting.participants.includes(user.username) + const canAttend = user && !isParticipant && meeting.status !== 'completed' && meeting.status !== 'cancelled' + const availableTransitions = STATUS_OPTIONS.filter((s) => s !== meeting.status) + const scheduledTime = meeting.meeting_time || meeting.scheduled_at + + return ( +
+ + +
+

📅 {meeting.meeting_code ? : `#${meeting.id}`}

+
+ {meeting.status} + {meeting.project_code && Project: {meeting.project_code}} + {meeting.milestone_code && Milestone: {meeting.milestone_code}} +
+
+ + {message && ( +
+ {message} +
+ )} + +
+ {/* Main content */} +
+ {editMode ? ( +
+ +
IDTitleStatusPriorityTypeSubtype
CodeTitleStatusPriorityTypeSubtype
#{i.id}{i.title}{i.task_code || `#${i.id}`}{i.title} {i.status} {i.priority} {i.task_type}{i.task_subtype || "-"}