From 2339787499514b329bc0096b3a00d3ef931915b3 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Mar 2026 02:03:40 -0400 Subject: [PATCH] feat: add supporting data capture, PDF export, and branding settings UI - API clients for supporting data CRUD and team branding - AddSupportingDataModal with text snippet and screenshot tabs (paste + upload) - SupportingDataPanel collapsible section integrated into both session runners - ExportPreviewModal updated with PDF format and server-side download flow - BrandingSettings component for company name and logo management - Expose team_id in UserResponse schema for branding endpoint access Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/schemas/user.py | 1 + frontend/src/api/branding.ts | 23 ++ frontend/src/api/supportingData.ts | 39 +++ .../session/AddSupportingDataModal.tsx | 263 ++++++++++++++++++ .../components/session/ExportPreviewModal.tsx | 131 +++++---- .../session/SupportingDataPanel.tsx | 152 ++++++++++ .../components/settings/BrandingSettings.tsx | 245 ++++++++++++++++ frontend/src/pages/AccountSettingsPage.tsx | 7 + .../src/pages/ProceduralNavigationPage.tsx | 8 + frontend/src/pages/SessionDetailPage.tsx | 31 ++- frontend/src/pages/TreeNavigationPage.tsx | 8 + frontend/src/types/session.ts | 2 +- frontend/src/types/user.ts | 1 + 13 files changed, 857 insertions(+), 54 deletions(-) create mode 100644 frontend/src/api/branding.ts create mode 100644 frontend/src/api/supportingData.ts create mode 100644 frontend/src/components/session/AddSupportingDataModal.tsx create mode 100644 frontend/src/components/session/SupportingDataPanel.tsx create mode 100644 frontend/src/components/settings/BrandingSettings.tsx diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 496c46e9..a48df65b 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -46,6 +46,7 @@ class UserResponse(UserBase): role: str = "engineer" account_id: Optional[UUID] = None account_role: Optional[str] = None + team_id: Optional[UUID] = None is_super_admin: bool = False is_active: bool = True must_change_password: bool = False diff --git a/frontend/src/api/branding.ts b/frontend/src/api/branding.ts new file mode 100644 index 00000000..37387efb --- /dev/null +++ b/frontend/src/api/branding.ts @@ -0,0 +1,23 @@ +import { apiClient } from './client' + +export interface BrandingInfo { + company_display_name: string | null + logo_content_type: string | null + has_logo: boolean +} + +export async function getBranding(teamId: string): Promise { + const response = await apiClient.get(`/teams/${teamId}/branding`) + return response.data +} + +export async function updateBranding(teamId: string, formData: FormData): Promise { + const response = await apiClient.patch(`/teams/${teamId}/branding`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data +} + +export async function deleteLogo(teamId: string): Promise { + await apiClient.delete(`/teams/${teamId}/branding/logo`) +} diff --git a/frontend/src/api/supportingData.ts b/frontend/src/api/supportingData.ts new file mode 100644 index 00000000..0dc36546 --- /dev/null +++ b/frontend/src/api/supportingData.ts @@ -0,0 +1,39 @@ +import { apiClient } from './client' + +export interface SupportingDataItem { + id: string + session_id: string + label: string + data_type: 'text_snippet' | 'screenshot' + content: string + content_type: string | null + sort_order: number + created_at: string + updated_at: string +} + +export async function getSupportingData(sessionId: string): Promise { + const response = await apiClient.get(`/sessions/${sessionId}/supporting-data`) + return response.data +} + +export async function createSupportingData( + sessionId: string, + data: { label: string; data_type: string; content: string; content_type?: string } +): Promise { + const response = await apiClient.post(`/sessions/${sessionId}/supporting-data`, data) + return response.data +} + +export async function updateSupportingData( + sessionId: string, + itemId: string, + data: { label?: string; content?: string } +): Promise { + const response = await apiClient.patch(`/sessions/${sessionId}/supporting-data/${itemId}`, data) + return response.data +} + +export async function deleteSupportingData(sessionId: string, itemId: string): Promise { + await apiClient.delete(`/sessions/${sessionId}/supporting-data/${itemId}`) +} diff --git a/frontend/src/components/session/AddSupportingDataModal.tsx b/frontend/src/components/session/AddSupportingDataModal.tsx new file mode 100644 index 00000000..23a85883 --- /dev/null +++ b/frontend/src/components/session/AddSupportingDataModal.tsx @@ -0,0 +1,263 @@ +import { useState, useRef, useCallback } from 'react' +import { Code2, ImageIcon, Upload } from 'lucide-react' +import { Modal } from '@/components/common/Modal' +import { Button } from '@/components/ui/Button' +import { cn } from '@/lib/utils' +import { createSupportingData } from '@/api/supportingData' +import { toast } from '@/lib/toast' + +interface AddSupportingDataModalProps { + isOpen: boolean + onClose: () => void + sessionId: string + onAdded: () => void +} + +type TabType = 'text_snippet' | 'screenshot' + +const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB + +export function AddSupportingDataModal({ isOpen, onClose, sessionId, onAdded }: AddSupportingDataModalProps) { + const [activeTab, setActiveTab] = useState('text_snippet') + const [label, setLabel] = useState('') + const [textContent, setTextContent] = useState('') + const [imageBase64, setImageBase64] = useState(null) + const [imageContentType, setImageContentType] = useState(null) + const [imageFileName, setImageFileName] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + const fileInputRef = useRef(null) + + const resetForm = () => { + setLabel('') + setTextContent('') + setImageBase64(null) + setImageContentType(null) + setImageFileName(null) + setError(null) + setActiveTab('text_snippet') + } + + const handleClose = () => { + resetForm() + onClose() + } + + const processFile = useCallback((file: File) => { + if (file.size > MAX_FILE_SIZE) { + setError('File must be under 2MB') + return + } + if (!['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) { + setError('Only PNG, JPEG, and SVG files are supported') + return + } + setError(null) + setImageFileName(file.name) + setImageContentType(file.type) + + const reader = new FileReader() + reader.onload = () => { + const result = reader.result as string + // Strip the data:... prefix to get raw base64 + const base64 = result.includes(',') ? result.split(',')[1] : result + setImageBase64(base64) + } + reader.readAsDataURL(file) + }, []) + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) processFile(file) + } + + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const items = e.clipboardData?.items + if (!items) return + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault() + const file = item.getAsFile() + if (file) processFile(file) + return + } + } + }, [processFile]) + + const handleSubmit = async () => { + if (!label.trim()) { + setError('Label is required') + return + } + + if (activeTab === 'text_snippet' && !textContent.trim()) { + setError('Content is required') + return + } + + if (activeTab === 'screenshot' && !imageBase64) { + setError('Please select or paste an image') + return + } + + setIsSubmitting(true) + setError(null) + try { + await createSupportingData(sessionId, { + label: label.trim(), + data_type: activeTab, + content: activeTab === 'text_snippet' ? textContent : imageBase64!, + content_type: activeTab === 'screenshot' ? (imageContentType ?? undefined) : undefined, + }) + toast.success('Supporting data added') + onAdded() + handleClose() + } catch (err) { + console.error('Failed to add supporting data:', err) + setError('Failed to save. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + return ( + + {/* Tabs */} +
+ + +
+ + {/* Label */} +
+ + setLabel(e.target.value)} + placeholder={activeTab === 'text_snippet' ? 'e.g. Error log output' : 'e.g. Blue screen photo'} + className={cn( + 'w-full rounded-md border border-border bg-card px-3 py-2 text-sm', + 'text-foreground placeholder:text-muted-foreground', + 'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20' + )} + /> +
+ + {/* Text Snippet Tab Content */} + {activeTab === 'text_snippet' && ( +
+ +