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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
23
frontend/src/api/branding.ts
Normal file
23
frontend/src/api/branding.ts
Normal file
@@ -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<BrandingInfo> {
|
||||
const response = await apiClient.get(`/teams/${teamId}/branding`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updateBranding(teamId: string, formData: FormData): Promise<BrandingInfo> {
|
||||
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<void> {
|
||||
await apiClient.delete(`/teams/${teamId}/branding/logo`)
|
||||
}
|
||||
39
frontend/src/api/supportingData.ts
Normal file
39
frontend/src/api/supportingData.ts
Normal file
@@ -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<SupportingDataItem[]> {
|
||||
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<SupportingDataItem> {
|
||||
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<SupportingDataItem> {
|
||||
const response = await apiClient.patch(`/sessions/${sessionId}/supporting-data/${itemId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deleteSupportingData(sessionId: string, itemId: string): Promise<void> {
|
||||
await apiClient.delete(`/sessions/${sessionId}/supporting-data/${itemId}`)
|
||||
}
|
||||
263
frontend/src/components/session/AddSupportingDataModal.tsx
Normal file
263
frontend/src/components/session/AddSupportingDataModal.tsx
Normal file
@@ -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<TabType>('text_snippet')
|
||||
const [label, setLabel] = useState('')
|
||||
const [textContent, setTextContent] = useState('')
|
||||
const [imageBase64, setImageBase64] = useState<string | null>(null)
|
||||
const [imageContentType, setImageContentType] = useState<string | null>(null)
|
||||
const [imageFileName, setImageFileName] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add Supporting Data">
|
||||
{/* Tabs */}
|
||||
<div className="mb-4 flex gap-1 rounded-lg bg-accent p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('text_snippet')}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
activeTab === 'text_snippet'
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Code2 className="h-4 w-4" />
|
||||
Text Snippet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('screenshot')}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
activeTab === 'screenshot'
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Screenshot
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="sd-label" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Label
|
||||
</label>
|
||||
<input
|
||||
id="sd-label"
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => 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'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text Snippet Tab Content */}
|
||||
{activeTab === 'text_snippet' && (
|
||||
<div className="mb-4">
|
||||
<label htmlFor="sd-content" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="sd-content"
|
||||
value={textContent}
|
||||
onChange={(e) => setTextContent(e.target.value)}
|
||||
placeholder="Paste log output, error messages, config snippets..."
|
||||
rows={8}
|
||||
className={cn(
|
||||
'w-full resize-y rounded-md border border-border bg-card px-3 py-2 text-sm',
|
||||
'font-mono text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screenshot Tab Content */}
|
||||
{activeTab === 'screenshot' && (
|
||||
<div className="mb-4" onPaste={handlePaste}>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||
Image
|
||||
</label>
|
||||
{imageBase64 ? (
|
||||
<div className="relative rounded-md border border-border bg-card p-2">
|
||||
<img
|
||||
src={`data:${imageContentType};base64,${imageBase64}`}
|
||||
alt="Preview"
|
||||
className="mx-auto max-h-48 rounded object-contain"
|
||||
/>
|
||||
<p className="mt-2 text-center text-xs text-muted-foreground">{imageFileName}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImageBase64(null)
|
||||
setImageContentType(null)
|
||||
setImageFileName(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}}
|
||||
className="mt-2 w-full text-center text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Remove and choose another
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed border-border',
|
||||
'bg-card/50 py-10 transition-colors hover:border-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Click to upload or paste from clipboard</p>
|
||||
<p className="text-xs text-muted-foreground">PNG, JPEG, or SVG - max 2MB</p>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="mb-4 text-sm text-rose-500">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddSupportingDataModal
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Copy, Download, Check, RotateCcw } from 'lucide-react'
|
||||
import { Copy, Download, Check, RotateCcw, FileDown } from 'lucide-react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { RedactionSummary } from '@/types'
|
||||
@@ -9,8 +9,10 @@ interface ExportPreviewModalProps {
|
||||
onClose: () => void
|
||||
content: string
|
||||
filename: string
|
||||
format: 'markdown' | 'text' | 'html' | 'psa'
|
||||
format: 'markdown' | 'text' | 'html' | 'psa' | 'pdf'
|
||||
onDownload: (content: string) => void
|
||||
onDownloadPdf?: () => void
|
||||
pdfLoading?: boolean
|
||||
includeSummary?: boolean
|
||||
onToggleSummary?: (include: boolean) => void
|
||||
redactionEnabled?: boolean
|
||||
@@ -25,6 +27,8 @@ export function ExportPreviewModal({
|
||||
filename,
|
||||
format,
|
||||
onDownload,
|
||||
onDownloadPdf,
|
||||
pdfLoading = false,
|
||||
includeSummary = false,
|
||||
onToggleSummary,
|
||||
redactionEnabled = false,
|
||||
@@ -72,7 +76,7 @@ export function ExportPreviewModal({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Filename: <span className="font-mono text-foreground">{filename}</span>
|
||||
<span className="ml-3 rounded bg-accent px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : 'Plain Text'}
|
||||
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : format === 'pdf' ? 'PDF' : 'Plain Text'}
|
||||
</span>
|
||||
{isModified && (
|
||||
<span className="ml-2 text-xs text-yellow-400">(edited)</span>
|
||||
@@ -130,56 +134,79 @@ export function ExportPreviewModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editable Content */}
|
||||
<label htmlFor="export-content" className="sr-only">
|
||||
Export content
|
||||
</label>
|
||||
<textarea
|
||||
id="export-content"
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
className={cn(
|
||||
'h-96 w-full resize-y rounded-md border border-border bg-card p-4',
|
||||
'font-mono text-sm text-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
{format === 'pdf' ? (
|
||||
/* PDF download-only UI */
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<FileDown className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-muted-foreground mb-4">PDF exports are generated server-side with your team's branding.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownloadPdf}
|
||||
disabled={pdfLoading}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-6 py-3 text-sm font-semibold text-[#101114]',
|
||||
'hover:opacity-90 active:scale-[0.97] transition-all',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{pdfLoading ? 'Generating PDF...' : 'Download PDF'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Editable Content */}
|
||||
<label htmlFor="export-content" className="sr-only">
|
||||
Export content
|
||||
</label>
|
||||
<textarea
|
||||
id="export-content"
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
className={cn(
|
||||
'h-96 w-full resize-y rounded-md border border-border bg-card p-4',
|
||||
'font-mono text-sm text-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy to Clipboard
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
|
||||
'hover:opacity-90 focus:outline-hidden focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy to Clipboard
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
|
||||
'hover:opacity-90 focus:outline-hidden focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
152
frontend/src/components/session/SupportingDataPanel.tsx
Normal file
152
frontend/src/components/session/SupportingDataPanel.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Code2, ImageIcon, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getSupportingData, deleteSupportingData } from '@/api/supportingData'
|
||||
import type { SupportingDataItem } from '@/api/supportingData'
|
||||
import { AddSupportingDataModal } from './AddSupportingDataModal'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface SupportingDataPanelProps {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export function SupportingDataPanel({ sessionId }: SupportingDataPanelProps) {
|
||||
const [items, setItems] = useState<SupportingDataItem[]>([])
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await getSupportingData(sessionId)
|
||||
setItems(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load supporting data:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
loadItems()
|
||||
}, [loadItems])
|
||||
|
||||
const handleDelete = async (itemId: string) => {
|
||||
try {
|
||||
await deleteSupportingData(sessionId, itemId)
|
||||
setItems((prev) => prev.filter((item) => item.id !== itemId))
|
||||
toast.success('Supporting data removed')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete supporting data:', err)
|
||||
toast.error('Failed to remove item')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdded = () => {
|
||||
loadItems()
|
||||
setIsExpanded(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card/50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-foreground hover:text-foreground/80"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
Supporting Data
|
||||
{items.length > 0 && (
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-[10px] px-3 py-1.5 text-xs font-medium',
|
||||
'bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Items list */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
{isLoading && items.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">Loading...</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
No supporting data yet. Add text snippets or screenshots to include in exports.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 py-1">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border border-border bg-card/30 px-3 py-2.5',
|
||||
'group transition-colors hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0 text-muted-foreground">
|
||||
{item.data_type === 'text_snippet' ? (
|
||||
<Code2 className="h-4 w-4" />
|
||||
) : (
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{item.label}</p>
|
||||
{item.data_type === 'text_snippet' && item.content && (
|
||||
<p className="mt-0.5 truncate text-xs font-mono text-muted-foreground">
|
||||
{item.content.length > 120 ? item.content.slice(0, 120) + '...' : item.content}
|
||||
</p>
|
||||
)}
|
||||
{item.data_type === 'screenshot' && item.content && (
|
||||
<img
|
||||
src={`data:${item.content_type || 'image/png'};base64,${item.content}`}
|
||||
alt={item.label}
|
||||
className="mt-1 max-h-16 rounded border border-border object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-rose-500 group-hover:opacity-100"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Modal */}
|
||||
<AddSupportingDataModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
sessionId={sessionId}
|
||||
onAdded={handleAdded}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SupportingDataPanel
|
||||
245
frontend/src/components/settings/BrandingSettings.tsx
Normal file
245
frontend/src/components/settings/BrandingSettings.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Palette, Upload, Trash2, Loader2 } from 'lucide-react'
|
||||
import { getBranding, updateBranding, deleteLogo } from '@/api/branding'
|
||||
import type { BrandingInfo } from '@/api/branding'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface BrandingSettingsProps {
|
||||
teamId: string
|
||||
}
|
||||
|
||||
const MAX_LOGO_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
|
||||
export function BrandingSettings({ teamId }: BrandingSettingsProps) {
|
||||
const [branding, setBranding] = useState<BrandingInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [companyName, setCompanyName] = useState('')
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null)
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadBranding()
|
||||
}, [teamId])
|
||||
|
||||
const loadBranding = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await getBranding(teamId)
|
||||
setBranding(data)
|
||||
setCompanyName(data.company_display_name || '')
|
||||
if (data.has_logo) {
|
||||
// Construct logo URL from the API
|
||||
const token = localStorage.getItem('access_token')
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
setLogoPreview(`${baseUrl}/api/v1/teams/${teamId}/branding/logo?token=${token}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load branding:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_LOGO_SIZE) {
|
||||
setError('Logo 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)
|
||||
setLogoFile(file)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => setLogoPreview(reader.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
if (companyName.trim()) {
|
||||
formData.append('company_display_name', companyName.trim())
|
||||
} else {
|
||||
formData.append('company_display_name', '')
|
||||
}
|
||||
if (logoFile) {
|
||||
formData.append('logo', logoFile)
|
||||
}
|
||||
|
||||
const updated = await updateBranding(teamId, formData)
|
||||
setBranding(updated)
|
||||
setLogoFile(null)
|
||||
toast.success('Branding settings saved')
|
||||
} catch (err) {
|
||||
console.error('Failed to save branding:', err)
|
||||
setError('Failed to save. Please try again.')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteLogo = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteLogo(teamId)
|
||||
setBranding((prev) => prev ? { ...prev, has_logo: false, logo_content_type: null } : prev)
|
||||
setLogoPreview(null)
|
||||
setLogoFile(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
toast.success('Logo removed')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete logo:', err)
|
||||
toast.error('Failed to remove logo')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="glass-card-static p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasChanges = companyName !== (branding?.company_display_name || '') || logoFile !== null
|
||||
|
||||
return (
|
||||
<div className="glass-card-static p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Customize your company branding for PDF exports
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-5">
|
||||
{/* Company Display Name */}
|
||||
<div>
|
||||
<label htmlFor="company-name" className="block text-sm font-medium text-foreground">
|
||||
Company Display Name
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shown in the header of exported PDF reports
|
||||
</p>
|
||||
<input
|
||||
id="company-name"
|
||||
type="text"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="Your Company Name"
|
||||
className={cn(
|
||||
'mt-2 w-full max-w-md 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'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Company Logo
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PNG, JPEG, or SVG - max 2MB. Displayed in PDF export headers.
|
||||
</p>
|
||||
|
||||
{logoPreview ? (
|
||||
<div className="mt-2 flex items-start gap-4">
|
||||
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo preview"
|
||||
className="max-h-16 max-w-[200px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Change logo
|
||||
</button>
|
||||
{branding?.has_logo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteLogo}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center gap-1 text-sm text-rose-500 hover:text-rose-400 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Remove logo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'mt-2 flex max-w-md cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border',
|
||||
'bg-card/30 py-8 transition-colors hover:border-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Upload className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Click to upload logo</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-sm text-rose-500">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Save */}
|
||||
{hasChanges && (
|
||||
<div className="pt-2">
|
||||
<Button onClick={handleSave} loading={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Branding'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BrandingSettings
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { BrandingSettings } from '@/components/settings/BrandingSettings'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import type { Account, AccountMember, AccountInvite } from '@/types'
|
||||
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
|
||||
@@ -21,6 +22,7 @@ export function AccountSettingsPage() {
|
||||
const { isAccountOwner } = usePermissions()
|
||||
const { plan, limits, usage } = useSubscription()
|
||||
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const subscription = useAuthStore((s) => s.subscription)
|
||||
|
||||
const [account, setAccount] = useState<Account | null>(null)
|
||||
@@ -587,6 +589,11 @@ export function AccountSettingsPage() {
|
||||
<span className="text-muted-foreground group-hover:text-foreground transition-colors">→</span>
|
||||
</Link>
|
||||
|
||||
{/* Branding Section (owners only) */}
|
||||
{isAccountOwner && user?.team_id && (
|
||||
<BrandingSettings teamId={user.team_id} />
|
||||
)}
|
||||
|
||||
{/* Preferences Section */}
|
||||
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
|
||||
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
|
||||
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
|
||||
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
|
||||
import { SupportingDataPanel } from '@/components/session/SupportingDataPanel'
|
||||
import { integrationsApi, sessionPsaApi } from '@/api/integrations'
|
||||
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
|
||||
import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator'
|
||||
@@ -792,6 +793,13 @@ export function ProceduralNavigationPage() {
|
||||
<StepFeedback stepId={currentStep.id} sessionId={session.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supporting Data */}
|
||||
{session && (
|
||||
<div className="mt-4">
|
||||
<SupportingDataPanel sessionId={session.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Copilot - in-flow panel */}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function SessionDetailPage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html' | 'psa'>(defaultExportFormat)
|
||||
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html' | 'psa' | 'pdf'>(defaultExportFormat)
|
||||
const [exportContent, setExportContent] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -51,6 +51,7 @@ export function SessionDetailPage() {
|
||||
const [redactionMode, setRedactionMode] = useState<'none' | 'mask'>('none')
|
||||
const [redactionSummary, setRedactionSummary] = useState<RedactionSummary | null>(null)
|
||||
const [isGeneratingFlow, setIsGeneratingFlow] = useState(false)
|
||||
const [pdfLoading, setPdfLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -223,6 +224,31 @@ export function SessionDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!session) return
|
||||
setPdfLoading(true)
|
||||
try {
|
||||
const { apiClient } = await import('@/api/client')
|
||||
const response = await apiClient.post(
|
||||
`/sessions/${session.id}/export`,
|
||||
{ format: 'pdf' },
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `session-export-${session.id}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
analytics.exportGenerated({ session_id: session.id, format: 'pdf' })
|
||||
} catch (error) {
|
||||
console.error('PDF export failed:', error)
|
||||
toast.error('Failed to generate PDF export')
|
||||
} finally {
|
||||
setPdfLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAsTree = async (data: SaveAsTreeRequest) => {
|
||||
if (!session) return
|
||||
setIsSavingTree(true)
|
||||
@@ -478,6 +504,7 @@ export function SessionDetailPage() {
|
||||
<option value="text">Plain Text</option>
|
||||
<option value="html">HTML</option>
|
||||
<option value="psa">PSA / Ticket Note</option>
|
||||
<option value="pdf">PDF</option>
|
||||
</select>
|
||||
{session.decisions.length > 1 && (
|
||||
<select
|
||||
@@ -546,6 +573,8 @@ export function SessionDetailPage() {
|
||||
filename={getFilename()}
|
||||
format={exportFormat}
|
||||
onDownload={handleDownload}
|
||||
onDownloadPdf={handleDownloadPdf}
|
||||
pdfLoading={pdfLoading}
|
||||
includeSummary={includeSummary}
|
||||
onToggleSummary={handleToggleSummary}
|
||||
redactionEnabled={redactionMode === 'mask'}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { StepFeedback } from '@/components/session/StepFeedback'
|
||||
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
||||
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
|
||||
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
|
||||
import { SupportingDataPanel } from '@/components/session/SupportingDataPanel'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { integrationsApi, sessionPsaApi } from '@/api/integrations'
|
||||
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
|
||||
@@ -1204,6 +1205,13 @@ export function TreeNavigationPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Supporting Data */}
|
||||
{session && (
|
||||
<div className="mt-4">
|
||||
<SupportingDataPanel sessionId={session.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back Button */}
|
||||
{pathTaken.length > 1 && (
|
||||
<button
|
||||
|
||||
@@ -103,7 +103,7 @@ export interface SessionUpdate {
|
||||
}
|
||||
|
||||
export interface SessionExport {
|
||||
format: 'text' | 'markdown' | 'html' | 'psa'
|
||||
format: 'text' | 'markdown' | 'html' | 'psa' | 'pdf'
|
||||
include_timestamps?: boolean
|
||||
include_tree_info?: boolean
|
||||
include_outcome_notes?: boolean
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface User {
|
||||
must_change_password: boolean
|
||||
account_id: string | null
|
||||
account_role: 'owner' | 'engineer' | 'viewer' | null
|
||||
team_id: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
phone: string | null
|
||||
|
||||
Reference in New Issue
Block a user