vboxes fix

This commit is contained in:
2026-03-19 14:28:09 +01:00
parent b7f7c1f7c0
commit 06dfbfcbc4
30 changed files with 4405 additions and 166 deletions
+59
View File
@@ -0,0 +1,59 @@
import apiClient from './client'
import type {
LabelCreate,
LabelResponse,
LabelUpdate,
MessageBulkLabelRequest,
MessageBulkLabelResponse,
MessageLabelAddRequest,
MessageLabelRemoveRequest,
MessageLabelSetRequest,
} from '@/types/api.types'
export const labelsApi = {
// ─── CRUD Tag ─────────────────────────────────────────────────────────────
list: () =>
apiClient.get<LabelResponse[]>('/labels').then((r) => r.data),
create: (data: LabelCreate) =>
apiClient.post<LabelResponse>('/labels', data).then((r) => r.data),
update: (id: string, data: LabelUpdate) =>
apiClient.patch<LabelResponse>(`/labels/${id}`, data).then((r) => r.data),
delete: (id: string) =>
apiClient.delete(`/labels/${id}`).then((r) => r.data),
// ─── Tag su singolo messaggio ─────────────────────────────────────────────
getMessageLabels: (messageId: string) =>
apiClient
.get<LabelResponse[]>(`/messages/${messageId}/labels`)
.then((r) => r.data),
/** Sostituisce tutti i tag di un messaggio. */
setMessageLabels: (messageId: string, data: MessageLabelSetRequest) =>
apiClient
.put<LabelResponse[]>(`/messages/${messageId}/labels`, data)
.then((r) => r.data),
/** Aggiunge tag a un messaggio senza rimuovere quelli esistenti. */
addMessageLabels: (messageId: string, data: MessageLabelAddRequest) =>
apiClient
.post<LabelResponse[]>(`/messages/${messageId}/labels/add`, data)
.then((r) => r.data),
/** Rimuove specifici tag da un messaggio. */
removeMessageLabels: (messageId: string, data: MessageLabelRemoveRequest) =>
apiClient
.post<LabelResponse[]>(`/messages/${messageId}/labels/remove`, data)
.then((r) => r.data),
// ─── Bulk ─────────────────────────────────────────────────────────────────
bulkLabels: (data: MessageBulkLabelRequest) =>
apiClient
.post<MessageBulkLabelResponse>('/messages/bulk-labels', data)
.then((r) => r.data),
}
+19 -2
View File
@@ -69,8 +69,25 @@ export const messagesApi = {
getAttachments: (id: string) =>
apiClient.get<AttachmentResponse[]>(`/messages/${id}/attachments`).then((r) => r.data),
getAttachmentUrl: (messageId: string, attachmentId: string) =>
`/api/v1/messages/${messageId}/attachments/${attachmentId}/download`,
/**
* Scarica un allegato autenticato e lo salva localmente.
* Utilizza apiClient (con Bearer token) per evitare il 401 che si ottiene
* navigando direttamente verso l'URL con un <a href>.
*/
downloadAttachment: async (messageId: string, attachmentId: string, filename: string): Promise<void> => {
const response = await apiClient.get(
`/messages/${messageId}/attachments/${attachmentId}/download`,
{ responseType: 'blob' },
)
const blobUrl = window.URL.createObjectURL(new Blob([response.data]))
const anchor = document.createElement('a')
anchor.href = blobUrl
anchor.setAttribute('download', filename)
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
window.URL.revokeObjectURL(blobUrl)
},
getReceipts: (id: string) =>
apiClient.get<MessageResponse[]>(`/messages/${id}/receipts`).then((r) => r.data),
+23
View File
@@ -12,10 +12,33 @@ export interface SendJobFilters {
status?: string
}
/** Payload esteso per l'invio multipart (include body_html) */
export interface SendPecMultipartRequest extends SendPecRequest {
body_html?: string
}
export const sendApi = {
/** Invio PEC semplice (JSON, senza allegati) retrocompatibile */
send: (data: SendPecRequest) =>
apiClient.post<SendJobResponse>('/send', data).then((r) => r.data),
/**
* Invio PEC con allegati tramite multipart/form-data.
*
* Il campo `data` viene serializzato come JSON string;
* i file vengono appesi come `attachments[]`.
*/
sendMultipart: (data: SendPecMultipartRequest, files: File[] = []) => {
const formData = new FormData()
formData.append('data', JSON.stringify(data))
files.forEach((file) => formData.append('attachments', file))
return apiClient
.post<SendJobResponse>('/send/multipart', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
.then((r) => r.data)
},
listJobs: (filters: SendJobFilters = {}) =>
apiClient.get<SendJobListResponse>('/send/jobs', { params: filters }).then((r) => r.data),
@@ -0,0 +1,517 @@
/**
* RichTextEditor editor di testo ricco in stile Roundcube.
*
* Usa TipTap (ProseMirror) con estensioni per:
* - Formattazione testo: grassetto, corsivo, sottolineato, barrato
* - Colore testo (palette + picker custom)
* - Titoli H1/H2/H3
* - Elenchi puntati, numerati, citazioni
* - Allineamento testo
* - Collegamento ipertestuale
* - Undo/Redo
* - Rimuovi formattazione
*/
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import UnderlineExt from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import { TextStyle } from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import { useEffect, useCallback, useState, useRef } from 'react'
import {
Bold,
Italic,
Underline,
Strikethrough,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
List,
ListOrdered,
Quote,
Link as LinkIcon,
Link2Off,
Undo2,
Redo2,
Baseline,
Eraser,
} from 'lucide-react'
// ─── Tipi ────────────────────────────────────────────────────────────────────
interface RichTextEditorProps {
value: string
onChange: (html: string) => void
placeholder?: string
minHeight?: string
}
// ─── Palette colori ───────────────────────────────────────────────────────────
const COLOR_PALETTE = [
// Grigi
'#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff',
// Vivaci
'#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#9900ff', '#ff00ff',
// Chiari
'#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#cfe2f3', '#d9d2e9', '#ead1dc',
// Standard
'#ea4335', '#fbbc04', '#34a853', '#4285f4', '#9334e6', '#c2185b', '#e65100', '#00838f',
]
// ─── Sub-componenti toolbar ───────────────────────────────────────────────────
interface ToolbarButtonProps {
onClick: () => void
isActive?: boolean
disabled?: boolean
title: string
children: React.ReactNode
}
function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
className={[
'flex items-center justify-center w-7 h-7 rounded text-sm transition-colors select-none',
isActive
? 'bg-primary/15 text-primary'
: 'text-foreground hover:bg-muted',
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer',
].join(' ')}
>
{children}
</button>
)
}
function ToolbarSeparator() {
return <div className="w-px h-5 bg-border mx-0.5 flex-shrink-0" />
}
// ─── Color Picker ─────────────────────────────────────────────────────────────
interface ColorPickerProps {
currentColor?: string
onSelect: (color: string) => void
onClose: () => void
}
function ColorPicker({ currentColor, onSelect, onClose }: ColorPickerProps) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose()
}
}
// Delay to avoid closing immediately on the button click that opened us
const timer = setTimeout(() => document.addEventListener('mousedown', handler), 50)
return () => {
clearTimeout(timer)
document.removeEventListener('mousedown', handler)
}
}, [onClose])
return (
<div
ref={ref}
className="absolute z-50 top-full left-0 mt-1 p-2.5 bg-background border rounded-lg shadow-xl min-w-max"
>
<div className="text-xs text-muted-foreground mb-1.5 font-medium">Colore testo</div>
<div className="grid grid-cols-8 gap-1 mb-2">
{COLOR_PALETTE.map((color) => (
<button
key={color}
type="button"
className={[
'w-5 h-5 rounded-sm border transition-transform hover:scale-110',
currentColor === color ? 'ring-2 ring-primary ring-offset-1' : 'border-border/40',
].join(' ')}
style={{ backgroundColor: color }}
onClick={() => { onSelect(color); onClose() }}
title={color}
/>
))}
</div>
<div className="flex items-center gap-2 pt-1.5 border-t">
<label className="text-xs text-muted-foreground">Personalizzato:</label>
<input
type="color"
className="w-7 h-6 rounded cursor-pointer border border-border"
defaultValue={currentColor || '#000000'}
onChange={(e) => onSelect(e.target.value)}
/>
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground underline ml-auto"
onClick={() => { onSelect(''); onClose() }}
>
Rimuovi
</button>
</div>
</div>
)
}
// ─── Link Dialog ──────────────────────────────────────────────────────────────
interface LinkDialogProps {
currentUrl?: string
onConfirm: (url: string) => void
onRemove: () => void
onClose: () => void
}
function LinkDialog({ currentUrl, onConfirm, onRemove, onClose }: LinkDialogProps) {
const [url, setUrl] = useState(currentUrl || 'https://')
const ref = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const timer = setTimeout(() => inputRef.current?.focus(), 50)
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
}
setTimeout(() => document.addEventListener('mousedown', handler), 50)
return () => {
clearTimeout(timer)
document.removeEventListener('mousedown', handler)
}
}, [onClose])
const confirm = useCallback(() => {
if (url && url !== 'https://') {
onConfirm(url.startsWith('http') ? url : `https://${url}`)
}
onClose()
}, [url, onConfirm, onClose])
return (
<div
ref={ref}
className="absolute z-50 top-full left-0 mt-1 p-3 bg-background border rounded-lg shadow-xl w-80"
>
<div className="text-xs text-muted-foreground mb-2 font-medium">
{currentUrl ? 'Modifica collegamento' : 'Inserisci collegamento'}
</div>
<div className="flex gap-2">
<input
ref={inputRef}
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://esempio.it"
className="flex-1 text-sm border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring bg-background"
onKeyDown={(e) => {
if (e.key === 'Enter') confirm()
if (e.key === 'Escape') onClose()
}}
/>
<button
type="button"
className="px-3 py-1.5 bg-primary text-primary-foreground rounded text-sm font-medium hover:bg-primary/90"
onClick={confirm}
>
OK
</button>
{currentUrl && (
<button
type="button"
className="p-1.5 bg-destructive/10 text-destructive rounded hover:bg-destructive/20"
onClick={() => { onRemove(); onClose() }}
title="Rimuovi collegamento"
>
<Link2Off className="h-4 w-4" />
</button>
)}
</div>
</div>
)
}
// ─── Toolbar principale ───────────────────────────────────────────────────────
interface ToolbarProps {
editor: ReturnType<typeof useEditor>
}
function Toolbar({ editor }: ToolbarProps) {
const [showColorPicker, setShowColorPicker] = useState(false)
const [showLinkDialog, setShowLinkDialog] = useState(false)
const colorRef = useRef<HTMLDivElement>(null)
const linkRef = useRef<HTMLDivElement>(null)
if (!editor) return null
const currentColor = editor.getAttributes('textStyle').color as string | undefined
const currentLinkUrl = editor.getAttributes('link').href as string | undefined
return (
<div className="flex flex-wrap items-center gap-0.5 p-1.5 bg-muted/40 border-b overflow-x-auto">
{/* Undo / Redo */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Annulla (Ctrl+Z)"
>
<Undo2 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Ripeti (Ctrl+Y)"
>
<Redo2 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Formattazione di base */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Grassetto (Ctrl+B)"
>
<Bold className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Corsivo (Ctrl+I)"
>
<Italic className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
title="Sottolineato (Ctrl+U)"
>
<Underline className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title="Barrato"
>
<Strikethrough className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Colore testo */}
<div className="relative" ref={colorRef}>
<ToolbarButton
onClick={() => setShowColorPicker((v) => !v)}
isActive={showColorPicker}
title="Colore testo"
>
<span className="flex flex-col items-center leading-none">
<Baseline className="h-3 w-3" />
<span
className="block h-[3px] w-4 rounded-sm mt-0.5"
style={{ backgroundColor: currentColor || '#000000' }}
/>
</span>
</ToolbarButton>
{showColorPicker && (
<ColorPicker
currentColor={currentColor}
onSelect={(color) => {
if (color) {
editor.chain().focus().setColor(color).run()
} else {
editor.chain().focus().unsetColor().run()
}
}}
onClose={() => setShowColorPicker(false)}
/>
)}
</div>
{/* Rimuovi formattazione */}
<ToolbarButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Rimuovi formattazione"
>
<Eraser className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Titoli */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title="Titolo 1"
>
<span className="text-[11px] font-bold leading-none">H1</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Titolo 2"
>
<span className="text-[11px] font-bold leading-none">H2</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Titolo 3"
>
<span className="text-[11px] font-bold leading-none">H3</span>
</ToolbarButton>
<ToolbarSeparator />
{/* Elenchi */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Elenco puntato"
>
<List className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Elenco numerato"
>
<ListOrdered className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Citazione"
>
<Quote className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Allineamento */}
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
isActive={editor.isActive({ textAlign: 'left' })}
title="Allinea a sinistra"
>
<AlignLeft className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
isActive={editor.isActive({ textAlign: 'center' })}
title="Centra"
>
<AlignCenter className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
isActive={editor.isActive({ textAlign: 'right' })}
title="Allinea a destra"
>
<AlignRight className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
isActive={editor.isActive({ textAlign: 'justify' })}
title="Giustifica"
>
<AlignJustify className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Link */}
<div className="relative" ref={linkRef}>
<ToolbarButton
onClick={() => setShowLinkDialog((v) => !v)}
isActive={editor.isActive('link') || showLinkDialog}
title={editor.isActive('link') ? 'Modifica collegamento' : 'Inserisci collegamento'}
>
<LinkIcon className="h-3.5 w-3.5" />
</ToolbarButton>
{showLinkDialog && (
<LinkDialog
currentUrl={currentLinkUrl}
onConfirm={(url) =>
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
onRemove={() => editor.chain().focus().unsetLink().run()}
onClose={() => setShowLinkDialog(false)}
/>
)}
</div>
</div>
)
}
// ─── Componente principale ────────────────────────────────────────────────────
export function RichTextEditor({
value,
onChange,
placeholder = 'Scrivi il testo della PEC...',
minHeight = '260px',
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
UnderlineExt,
TextStyle,
Color,
TextAlign.configure({
types: ['heading', 'paragraph'],
defaultAlignment: 'left',
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'rte-link',
rel: 'noopener noreferrer',
},
}),
Placeholder.configure({ placeholder }),
],
content: value,
onUpdate: ({ editor }) => {
onChange(editor.getHTML())
},
editorProps: {
attributes: {
class: 'rte-content focus:outline-none',
},
},
})
// Sync da valore esterno (es. modifica replyTo dopo il mount)
useEffect(() => {
if (editor && !editor.isDestroyed && value !== editor.getHTML()) {
editor.commands.setContent(value, { emitUpdate: false })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
return (
<div className="border rounded-md overflow-hidden focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 bg-background">
<Toolbar editor={editor} />
<EditorContent
editor={editor}
style={{ minHeight }}
className="px-4 py-3 overflow-y-auto"
/>
</div>
)
}
@@ -0,0 +1,115 @@
/**
* TagBadge badge colorato per un singolo tag/label.
*
* Mostra il nome del tag con il colore di sfondo configurato.
* Se `onRemove` è fornito, mostra un pulsante × per rimuovere il tag.
*/
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { LabelResponse } from '@/types/api.types'
// Colore di default quando il tag non ha colore configurato
const DEFAULT_COLOR = '#6b7280'
/**
* Calcola il colore del testo (bianco o nero) in base alla luminosità
* del colore di sfondo per garantire un contrasto leggibile.
*/
function getTextColor(hexColor: string): string {
const hex = hexColor.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
// Formula luminosità relativa (WCAG)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.5 ? '#1f2937' : '#ffffff'
}
interface TagBadgeProps {
label: LabelResponse
onRemove?: () => void
size?: 'sm' | 'md'
className?: string
}
export function TagBadge({ label, onRemove, size = 'sm', className }: TagBadgeProps) {
const bgColor = label.color || DEFAULT_COLOR
const textColor = getTextColor(bgColor)
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full font-medium transition-all',
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
className,
)}
style={{ backgroundColor: bgColor, color: textColor }}
title={label.name}
>
<span className="truncate max-w-[120px]">{label.name}</span>
{onRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className="flex-shrink-0 rounded-full p-0.5 hover:opacity-70 transition-opacity"
style={{ color: textColor }}
aria-label={`Rimuovi tag ${label.name}`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
)
}
/**
* TagBadgeList mostra una lista compatta di tag badge.
* Se ci sono più tag del limite `maxVisible`, mostra un badge "+N".
*/
interface TagBadgeListProps {
labels: LabelResponse[]
onRemove?: (labelId: string) => void
maxVisible?: number
size?: 'sm' | 'md'
className?: string
}
export function TagBadgeList({
labels,
onRemove,
maxVisible = 3,
size = 'sm',
className,
}: TagBadgeListProps) {
if (!labels || labels.length === 0) return null
const visible = labels.slice(0, maxVisible)
const overflow = labels.length - maxVisible
return (
<div className={cn('flex items-center gap-1 flex-wrap', className)}>
{visible.map((label) => (
<TagBadge
key={label.id}
label={label}
onRemove={onRemove ? () => onRemove(label.id) : undefined}
size={size}
/>
))}
{overflow > 0 && (
<span
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground"
title={labels
.slice(maxVisible)
.map((l) => l.name)
.join(', ')}
>
+{overflow}
</span>
)}
</div>
)
}
@@ -0,0 +1,392 @@
/**
* TagSelector pannello di selezione/creazione tag per messaggi.
*
* Modalità d'uso:
* - single: apre un Dialog per impostare i tag di un singolo messaggio
* - bulk: apre un Dialog per aggiungere o rimuovere tag da più messaggi
*
* Funzionalità:
* - Lista tutti i tag del tenant con checkbox
* - Ricerca/filtro tag
* - Crea nuovi tag con nome e colore (solo per admin)
* - Mostra anteprima del badge colorato
*/
import { useState, useMemo } from 'react'
import { Plus, Search, Tag, Check, Trash2 } from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
import { TagBadge } from './TagBadge'
import { labelsApi } from '@/api/labels.api'
import { getErrorMessage } from '@/api/client'
import { useAuth } from '@/hooks/useAuth'
import { cn } from '@/lib/utils'
import type { LabelResponse } from '@/types/api.types'
// ─── Tavolozza colori predefiniti ─────────────────────────────────────────────
const PRESET_COLORS = [
'#ef4444', // rosso
'#f97316', // arancione
'#eab308', // giallo
'#22c55e', // verde
'#14b8a6', // teal
'#3b82f6', // blu
'#8b5cf6', // viola
'#ec4899', // rosa
'#6b7280', // grigio
'#78716c', // marrone
]
// ─── Tipi ──────────────────────────────────────────────────────────────────────
interface TagSelectorSingleProps {
mode: 'single'
open: boolean
onClose: () => void
/** ID dei tag attualmente assegnati al messaggio */
currentLabelIds: string[]
/** Chiamata quando l'utente conferma la selezione */
onApply: (labelIds: string[]) => void
isApplying?: boolean
}
interface TagSelectorBulkProps {
mode: 'bulk'
open: boolean
onClose: () => void
/** Numero di messaggi selezionati (per info nell'UI) */
messageCount: number
/** Chiamata con i label selezionati e l'azione (add/remove) */
onApply: (labelIds: string[], action: 'add' | 'remove') => void
isApplying?: boolean
}
type TagSelectorProps = TagSelectorSingleProps | TagSelectorBulkProps
// ─── Componente principale ────────────────────────────────────────────────────
export function TagSelector(props: TagSelectorProps) {
const { open, onClose, onApply, isApplying } = props
const { user } = useAuth()
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'
const queryClient = useQueryClient()
// Stato selezione (per single: IDs scelti; per bulk: IDs da applicare)
const [selectedIds, setSelectedIds] = useState<Set<string>>(
props.mode === 'single' ? new Set(props.currentLabelIds) : new Set(),
)
const [searchQuery, setSearchQuery] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState(PRESET_COLORS[5]) // blu default
// Reset dello stato all'apertura
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
setSearchQuery('')
setShowCreate(false)
setNewName('')
setNewColor(PRESET_COLORS[5])
if (props.mode === 'bulk') {
setSelectedIds(new Set())
}
onClose()
} else if (props.mode === 'single') {
setSelectedIds(new Set(props.currentLabelIds))
}
}
// Carica lista tag del tenant
const { data: allLabels = [], isLoading } = useQuery({
queryKey: ['labels'],
queryFn: labelsApi.list,
staleTime: 5 * 60 * 1000,
enabled: open,
})
// Tag filtrati per ricerca
const filteredLabels = useMemo(() => {
if (!searchQuery.trim()) return allLabels
const q = searchQuery.toLowerCase()
return allLabels.filter((l) => l.name.toLowerCase().includes(q))
}, [allLabels, searchQuery])
// Crea nuovo tag
const createMutation = useMutation<LabelResponse, Error, void>({
mutationFn: () => labelsApi.create({ name: newName.trim(), color: newColor }),
onSuccess: (newLabel) => {
queryClient.invalidateQueries({ queryKey: ['labels'] })
setSelectedIds((prev) => new Set([...prev, newLabel.id]))
setNewName('')
setShowCreate(false)
toast.success(`Tag "${newLabel.name}" creato`)
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// Elimina tag
const deleteMutation = useMutation({
mutationFn: (id: string) => labelsApi.delete(id),
onSuccess: (_, deletedId) => {
queryClient.invalidateQueries({ queryKey: ['labels'] })
setSelectedIds((prev) => {
const next = new Set(prev)
next.delete(deletedId)
return next
})
toast.success('Tag eliminato')
},
onError: (error) => toast.error(getErrorMessage(error)),
})
const toggleLabel = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleApplySingle = () => {
if (props.mode === 'single') {
onApply(Array.from(selectedIds))
}
}
const handleApplyBulk = (action: 'add' | 'remove') => {
if (props.mode === 'bulk') {
onApply(Array.from(selectedIds), action)
}
}
const canConfirm = selectedIds.size > 0
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Tag className="h-5 w-5 text-primary" />
{props.mode === 'single' ? 'Gestisci tag' : `Assegna tag a ${props.mode === 'bulk' ? props.messageCount : ''} messaggi`}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Ricerca tag */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cerca tag…"
className="pl-9 h-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
</div>
{/* Lista tag */}
<div className="max-h-60 overflow-y-auto space-y-1 rounded-lg border bg-muted/20 p-2">
{isLoading ? (
<div className="flex items-center justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : filteredLabels.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4">
{searchQuery ? 'Nessun tag trovato' : 'Nessun tag disponibile'}
</p>
) : (
filteredLabels.map((label) => (
<LabelRow
key={label.id}
label={label}
isSelected={selectedIds.has(label.id)}
onToggle={() => toggleLabel(label.id)}
onDelete={isAdmin ? () => deleteMutation.mutate(label.id) : undefined}
isDeleting={deleteMutation.isPending && deleteMutation.variables === label.id}
/>
))
)}
</div>
{/* Crea nuovo tag (admin only) */}
{isAdmin && (
<div className="border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowCreate(!showCreate)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-muted/50 transition-colors"
>
<Plus className="h-4 w-4 text-primary flex-shrink-0" />
<span className="text-primary font-medium">Crea nuovo tag</span>
</button>
{showCreate && (
<div className="px-3 pb-3 pt-1 border-t bg-muted/10 space-y-3">
<Input
placeholder="Nome tag…"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="h-8 text-sm"
maxLength={100}
onKeyDown={(e) => {
if (e.key === 'Enter' && newName.trim()) createMutation.mutate()
}}
/>
{/* Selettore colore */}
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground">Colore</p>
<div className="flex items-center gap-2 flex-wrap">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewColor(color)}
className={cn(
'h-6 w-6 rounded-full border-2 transition-all',
newColor === color
? 'border-foreground scale-110'
: 'border-transparent hover:scale-105',
)}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
</div>
{/* Anteprima badge */}
{newName.trim() && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Anteprima:</span>
<TagBadge
label={{ id: 'preview', tenant_id: '', name: newName.trim(), color: newColor }}
size="sm"
/>
</div>
)}
<Button
size="sm"
className="w-full h-8"
disabled={!newName.trim()}
onClick={() => createMutation.mutate()}
isLoading={createMutation.isPending}
>
Crea tag
</Button>
</div>
)}
</div>
)}
</div>
{/* Footer azioni */}
<DialogFooter className="flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => handleOpenChange(false)} className="sm:order-first">
Annulla
</Button>
{props.mode === 'single' ? (
<Button
onClick={handleApplySingle}
isLoading={isApplying}
>
Applica tag
</Button>
) : (
<div className="flex gap-2 flex-1 sm:justify-end">
<Button
variant="outline"
disabled={!canConfirm}
onClick={() => handleApplyBulk('remove')}
isLoading={isApplying}
className="flex-1 sm:flex-none"
>
Rimuovi
</Button>
<Button
disabled={!canConfirm}
onClick={() => handleApplyBulk('add')}
isLoading={isApplying}
className="flex-1 sm:flex-none"
>
Aggiungi
</Button>
</div>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ─── Riga singolo label nella lista ──────────────────────────────────────────
interface LabelRowProps {
label: LabelResponse
isSelected: boolean
onToggle: () => void
onDelete?: () => void
isDeleting?: boolean
}
function LabelRow({ label, isSelected, onToggle, onDelete, isDeleting }: LabelRowProps) {
return (
<div
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-muted/50 transition-colors group',
isSelected && 'bg-primary/5',
)}
onClick={onToggle}
>
{/* Checkbox */}
<div
className={cn(
'h-4 w-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors',
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/40',
)}
>
{isSelected && <Check className="h-3 w-3" />}
</div>
{/* Badge con colore */}
<TagBadge label={label} size="sm" className="pointer-events-none" />
{/* Spacer */}
<div className="flex-1" />
{/* Pulsante elimina (solo admin, visibile su hover) */}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className={cn(
'p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all',
isDeleting ? 'opacity-50 pointer-events-none' : 'opacity-0 group-hover:opacity-100',
)}
title="Elimina tag"
aria-label={`Elimina tag ${label.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
)
}
+76
View File
@@ -82,6 +82,82 @@
animation: spin 1s linear infinite;
}
/* ─── Rich Text Editor (TipTap / ProseMirror) ─────────────────────────────── */
.rte-content {
outline: none;
min-height: inherit;
line-height: 1.6;
font-size: 0.875rem;
}
/* Placeholder */
.rte-content p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: hsl(var(--muted-foreground));
float: left;
height: 0;
pointer-events: none;
}
/* Tipografia */
.rte-content h1 { font-size: 1.6rem; font-weight: 700; margin: 0.6rem 0 0.3rem; line-height: 1.3; }
.rte-content h2 { font-size: 1.3rem; font-weight: 600; margin: 0.5rem 0 0.25rem; line-height: 1.3; }
.rte-content h3 { font-size: 1.1rem; font-weight: 600; margin: 0.4rem 0 0.2rem; line-height: 1.3; }
.rte-content p { margin: 0.2rem 0; }
.rte-content p + p { margin-top: 0.4rem; }
/* Elenchi */
.rte-content ul { list-style-type: disc; padding-left: 1.5rem; margin: 0.3rem 0; }
.rte-content ol { list-style-type: decimal; padding-left: 1.5rem; margin: 0.3rem 0; }
.rte-content li { margin: 0.15rem 0; }
/* Citazione */
.rte-content blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 0.85rem;
color: hsl(var(--muted-foreground));
margin: 0.4rem 0;
font-style: italic;
}
/* Link */
.rte-content a.rte-link,
.rte-content a {
color: hsl(var(--primary));
text-decoration: underline;
cursor: pointer;
}
.rte-content a:hover { opacity: 0.8; }
/* Codice */
.rte-content code {
background: hsl(var(--muted));
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
font-family: ui-monospace, monospace;
font-size: 0.8em;
}
.rte-content pre {
background: hsl(222 47% 11%);
color: hsl(210 40% 96%);
padding: 0.75rem 1rem;
border-radius: 0.375rem;
overflow-x: auto;
margin: 0.4rem 0;
}
.rte-content pre code { background: none; color: inherit; padding: 0; }
/* Separatore orizzontale */
.rte-content hr {
border: none;
border-top: 1px solid hsl(var(--border));
margin: 0.6rem 0;
}
/* Selezione testo */
.rte-content .ProseMirror-selectednode { outline: 2px solid hsl(var(--primary)); }
/* Badge PEC stati */
.pec-badge-draft { @apply bg-gray-100 text-gray-700 border-gray-200; }
.pec-badge-queued { @apply bg-yellow-100 text-yellow-700 border-yellow-200; }
+197 -57
View File
@@ -1,12 +1,13 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useForm, useFieldArray } from 'react-hook-form'
import { Send, X, Plus, ArrowLeft, AlertCircle } from 'lucide-react'
import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload } from 'lucide-react'
import { useQuery, useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
import { sendApi } from '@/api/send.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { getErrorMessage } from '@/api/client'
@@ -17,7 +18,22 @@ interface ComposeFormValues {
to_addresses: { value: string }[]
cc_addresses: { value: string }[]
subject: string
body_text: string
}
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20 MB
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
/** Estrae testo semplice dall'HTML del rich editor per la parte text/plain dell'email */
function htmlToText(html: string): string {
if (!html || html === '<p></p>') return ''
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || div.innerText || ''
}
export function ComposePage() {
@@ -25,6 +41,27 @@ export function ComposePage() {
const location = useLocation()
const replyTo = location.state?.replyTo as MessageResponse | undefined
const [showCc, setShowCc] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Corpo HTML (gestito da TipTap)
const [bodyHtml, setBodyHtml] = useState<string>(() => {
if (!replyTo) return ''
const date = new Date(
replyTo.received_at || replyTo.created_at
).toLocaleDateString('it-IT')
return [
'<p></p>',
'<p></p>',
'<hr>',
`<p><strong>In risposta al messaggio del ${date}</strong></p>`,
`<p>Da: ${replyTo.from_address || ''}</p>`,
`<p>A: ${replyTo.to_addresses?.join(', ') || ''}</p>`,
`<p>Oggetto: ${replyTo.subject || ''}</p>`,
].join('')
})
// Allegati
const [attachments, setAttachments] = useState<File[]>([])
const {
register,
@@ -34,12 +71,11 @@ export function ComposePage() {
} = useForm<ComposeFormValues>({
defaultValues: {
mailbox_id: replyTo?.mailbox_id || '',
to_addresses: replyTo ? [{ value: replyTo.from_address || '' }] : [{ value: '' }],
to_addresses: replyTo
? [{ value: replyTo.from_address || '' }]
: [{ value: '' }],
cc_addresses: [],
subject: replyTo ? `Re: ${replyTo.subject || ''}` : '',
body_text: replyTo
? `\n\n---\nIn risposta al messaggio del ${new Date(replyTo.received_at || replyTo.created_at).toLocaleDateString('it-IT')}\nDa: ${replyTo.from_address || ''}\nA: ${replyTo.to_addresses?.join(', ') || ''}\nOggetto: ${replyTo.subject || ''}`
: '',
},
})
@@ -62,7 +98,10 @@ export function ComposePage() {
})
const sendMutation = useMutation({
mutationFn: sendApi.send,
mutationFn: (args: {
data: Parameters<typeof sendApi.sendMultipart>[0]
files: File[]
}) => sendApi.sendMultipart(args.data, args.files),
onSuccess: (job) => {
toast.success(`PEC inviata! Job ID: ${job.id.slice(0, 8)}...`)
navigate('/sent')
@@ -72,8 +111,29 @@ export function ComposePage() {
},
})
const onSubmit = async (data: ComposeFormValues) => {
const toAddresses = data.to_addresses
// ── Gestione allegati ──────────────────────────────────────────────────────
const handleFileAdd = (files: FileList | null) => {
if (!files) return
const valid: File[] = []
for (const file of Array.from(files)) {
if (file.size > MAX_FILE_SIZE) {
toast.error(`"${file.name}" supera il limite di 20 MB`)
} else {
valid.push(file)
}
}
if (valid.length) setAttachments((prev) => [...prev, ...valid])
}
const removeAttachment = (idx: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== idx))
}
// ── Submit ─────────────────────────────────────────────────────────────────
const onSubmit = async (formData: ComposeFormValues) => {
const toAddresses = formData.to_addresses
.map((t) => t.value.trim())
.filter((v) => v.length > 0)
@@ -83,14 +143,18 @@ export function ComposePage() {
}
await sendMutation.mutateAsync({
mailbox_id: data.mailbox_id,
to_addresses: toAddresses,
cc_addresses: data.cc_addresses
.map((c) => c.value.trim())
.filter((v) => v.length > 0),
subject: data.subject,
body_text: data.body_text,
reply_to_message_id: replyTo?.id,
data: {
mailbox_id: formData.mailbox_id,
to_addresses: toAddresses,
cc_addresses: formData.cc_addresses
.map((c) => c.value.trim())
.filter((v) => v.length > 0),
subject: formData.subject,
body_text: htmlToText(bodyHtml),
body_html: bodyHtml,
reply_to_message_id: replyTo?.id,
},
files: attachments,
})
}
@@ -99,28 +163,29 @@ export function ComposePage() {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-lg font-semibold">
{replyTo ? 'Rispondi a PEC' : 'Nuova PEC'}
</h1>
{replyTo && (
<p className="text-xs text-muted-foreground">
In risposta a: {replyTo.subject}
</p>
)}
</div>
<div className="border-b bg-background px-6 py-4 flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-lg font-semibold">
{replyTo ? 'Rispondi a PEC' : 'Nuova PEC'}
</h1>
{replyTo && (
<p className="text-xs text-muted-foreground">
In risposta a: {replyTo.subject}
</p>
)}
</div>
</div>
{/* Form */}
<div className="flex-1 overflow-y-auto">
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl mx-auto px-6 py-8 space-y-6">
{/* Avviso */}
<form
onSubmit={handleSubmit(onSubmit)}
className="max-w-4xl mx-auto px-6 py-6 space-y-5"
>
{/* Avviso informativo */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-sm text-blue-700">
@@ -130,7 +195,7 @@ export function ComposePage() {
</div>
{/* Casella mittente */}
<div className="space-y-2">
<div className="space-y-1.5">
<Label htmlFor="mailbox_id">Casella mittente *</Label>
{mailboxesLoading ? (
<div className="h-10 rounded-md border bg-muted animate-pulse" />
@@ -141,8 +206,10 @@ export function ComposePage() {
) : (
<select
id="mailbox_id"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
{...register('mailbox_id', { required: 'Seleziona una casella mittente' })}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
{...register('mailbox_id', {
required: 'Seleziona una casella mittente',
})}
>
<option value="">Seleziona casella...</option>
{activeCaselle.map((mb) => (
@@ -158,7 +225,7 @@ export function ComposePage() {
</div>
{/* Destinatari A: */}
<div className="space-y-2">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>Destinatari (A:) *</Label>
<Button
@@ -178,7 +245,8 @@ export function ComposePage() {
type="email"
placeholder="destinatario@pec.it"
{...register(`to_addresses.${idx}.value`, {
required: idx === 0 ? 'Almeno un destinatario è obbligatorio' : false,
required:
idx === 0 ? 'Almeno un destinatario è obbligatorio' : false,
})}
/>
{toFields.length > 1 && (
@@ -202,7 +270,7 @@ export function ComposePage() {
</div>
{/* CC */}
<div className="space-y-2">
<div className="space-y-1.5">
{!showCc ? (
<button
type="button"
@@ -247,7 +315,7 @@ export function ComposePage() {
</div>
{/* Oggetto */}
<div className="space-y-2">
<div className="space-y-1.5">
<Label htmlFor="subject">Oggetto *</Label>
<Input
id="subject"
@@ -259,25 +327,92 @@ export function ComposePage() {
)}
</div>
{/* Corpo */}
<div className="space-y-2">
<Label htmlFor="body_text">Testo del messaggio</Label>
<textarea
id="body_text"
rows={12}
placeholder="Testo della PEC..."
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
{...register('body_text')}
{/* Corpo Rich Text Editor */}
<div className="space-y-1.5">
<Label>Testo del messaggio</Label>
<RichTextEditor
value={bodyHtml}
onChange={setBodyHtml}
placeholder="Scrivi il testo della PEC..."
minHeight="280px"
/>
</div>
{/* Azioni */}
<div className="flex items-center justify-between pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
{/* ── Allegati ────────────────────────────────────────────────── */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Allegati</Label>
<span className="text-xs text-muted-foreground">Max 20 MB per file</span>
</div>
{/* Lista allegati caricati */}
{attachments.length > 0 && (
<div className="space-y-1.5">
{attachments.map((file, idx) => (
<div
key={`${file.name}-${file.size}-${idx}`}
className="flex items-center gap-2.5 px-3 py-2 bg-muted/50 border rounded-md"
>
<Paperclip className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span
className="text-sm flex-1 truncate font-medium"
title={file.name}
>
{file.name}
</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatFileSize(file.size)}
</span>
<button
type="button"
className="ml-1 rounded p-0.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
onClick={() => removeAttachment(idx)}
title="Rimuovi allegato"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{/* Zona drag-and-drop / click per aggiungere file */}
<div
className="border-2 border-dashed rounded-md p-5 flex flex-col items-center gap-2 cursor-pointer hover:border-primary/60 hover:bg-primary/5 transition-colors"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onDrop={(e) => {
e.preventDefault()
handleFileAdd(e.dataTransfer.files)
}}
>
<Upload className="h-7 w-7 text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium text-foreground">
Clicca o trascina i file qui
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Qualsiasi tipo di file Max 20 MB per file
</p>
</div>
</div>
{/* Input file nascosto */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFileAdd(e.target.files)}
/>
</div>
{/* ── Azioni ──────────────────────────────────────────────────── */}
<div className="flex items-center justify-between pt-4 border-t">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
Annulla
</Button>
<Button
@@ -287,6 +422,11 @@ export function ComposePage() {
>
<Send className="h-4 w-4 mr-2" />
Invia PEC
{attachments.length > 0 && (
<span className="ml-1.5 bg-primary-foreground/20 text-primary-foreground rounded-full px-1.5 text-xs font-semibold">
+{attachments.length}
</span>
)}
</Button>
</div>
</form>
+64 -2
View File
@@ -9,9 +9,9 @@
*
* Funzionalità:
* - Selezione singola e multipla tramite checkbox
* - Barra azioni bulk (stella, rimuovi stella, archivia, rimuovi archivio)
* - Barra azioni bulk (stella, rimuovi stella, archivia, rimuovi archivio, assegna tag)
* - Pulsanti azione rapida su hover di ogni riga (stella, archivia)
* - Tutte le azioni funzionano anche in senso inverso (unstar / unarchive)
* - Badge tag colorati per ogni messaggio
*/
import { useEffect, useState, useCallback } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
@@ -31,13 +31,17 @@ import {
CheckSquare,
Square,
X,
Tag,
} from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
import { TagBadgeList } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector'
import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import { formatRelative, truncate } from '@/lib/utils'
@@ -75,6 +79,9 @@ export function InboxPage({ viewMode }: InboxPageProps) {
// ── Stato selezione ─────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// ── Stato dialog tag bulk ────────────────────────────────────────────────────
const [showTagSelector, setShowTagSelector] = useState(false)
// Resetta lo stato UI ogni volta che si cambia casella, virtual box o cartella
useEffect(() => {
setSearchInput('')
@@ -248,6 +255,31 @@ export function InboxPage({ viewMode }: InboxPageProps) {
onError: (error) => toast.error(getErrorMessage(error)),
})
// ── Bulk tag ─────────────────────────────────────────────────────────────────
const bulkLabelMutation = useMutation({
mutationFn: labelsApi.bulkLabels,
onSuccess: (result, payload) => {
invalidateMessages()
setSelectedIds(new Set())
setShowTagSelector(false)
const n = result.updated
if (payload.action === 'add') {
toast.success(`Tag aggiunti a ${n} ${n === 1 ? 'messaggio' : 'messaggi'}`)
} else {
toast.success(`Tag rimossi da ${n} ${n === 1 ? 'messaggio' : 'messaggi'}`)
}
},
onError: (error) => toast.error(getErrorMessage(error)),
})
const handleBulkTag = (labelIds: string[], action: 'add' | 'remove') => {
bulkLabelMutation.mutate({
message_ids: Array.from(selectedIds),
label_ids: labelIds,
action,
})
}
const handleBulkStar = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: true })
const handleBulkUnstar = () =>
@@ -495,6 +527,17 @@ export function InboxPage({ viewMode }: InboxPageProps) {
Ripristina dalla posta
</Button>
)}
{/* Assegna tag bulk */}
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={() => setShowTagSelector(true)}
>
<Tag className="h-3.5 w-3.5 mr-1 text-primary" />
Tag
</Button>
</div>
)}
@@ -599,6 +642,18 @@ export function InboxPage({ viewMode }: InboxPageProps) {
</div>
</div>
)}
{/* ── Dialog assegnazione tag bulk ── */}
{showTagSelector && (
<TagSelector
mode="bulk"
open={showTagSelector}
onClose={() => setShowTagSelector(false)}
messageCount={selectedCount}
onApply={handleBulkTag}
isApplying={bulkLabelMutation.isPending}
/>
)}
</div>
)
}
@@ -715,6 +770,13 @@ function MessageRow({
</p>
</div>
{/* Tag badges */}
{message.labels && message.labels.length > 0 && (
<div className="mt-1" onClick={(e) => e.stopPropagation()}>
<TagBadgeList labels={message.labels} maxVisible={4} size="sm" />
</div>
)}
{message.body_text && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{truncate(message.body_text, 120)}
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
@@ -10,12 +11,16 @@ import {
Paperclip,
Mail,
Send,
Tag,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
import { TagBadge } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector'
import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api'
import { formatDate, formatBytes } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
@@ -24,6 +29,9 @@ export function MessageDetailPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
// Dialog tag
const [showTagSelector, setShowTagSelector] = useState(false)
// Carica messaggio
const {
data: message,
@@ -72,6 +80,15 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)),
})
// Download allegato autenticato
const handleDownloadAttachment = async (att: { id: string; filename: string }) => {
try {
await messagesApi.downloadAttachment(message?.id ?? id!, att.id, att.filename)
} catch (error) {
toast.error(getErrorMessage(error))
}
}
// Ripristina dall'archivio
const unarchiveMutation = useMutation({
mutationFn: () => messagesApi.unarchive(id!),
@@ -83,6 +100,38 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)),
})
// Imposta tag del messaggio
const setLabelsMutation = useMutation({
mutationFn: (labelIds: string[]) =>
labelsApi.setMessageLabels(id!, { label_ids: labelIds }),
onSuccess: (updatedLabels) => {
// Aggiorna la cache del messaggio con i nuovi label
queryClient.setQueryData(['message', id], (old: typeof message) => {
if (!old) return old
return { ...old, labels: updatedLabels }
})
// Invalida la lista messaggi per aggiornare i badge nella inbox
queryClient.invalidateQueries({ queryKey: ['messages'] })
setShowTagSelector(false)
toast.success('Tag aggiornati')
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// Rimuove singolo tag (click su × nel badge)
const removeLabelMutation = useMutation({
mutationFn: (labelId: string) =>
labelsApi.removeMessageLabels(id!, { label_ids: [labelId] }),
onSuccess: (updatedLabels) => {
queryClient.setQueryData(['message', id], (old: typeof message) => {
if (!old) return old
return { ...old, labels: updatedLabels }
})
queryClient.invalidateQueries({ queryKey: ['messages'] })
},
onError: (error) => toast.error(getErrorMessage(error)),
})
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -103,6 +152,8 @@ export function MessageDetailPage() {
)
}
const currentLabelIds = (message.labels || []).map((l) => l.id)
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
@@ -117,6 +168,22 @@ export function MessageDetailPage() {
</div>
<div className="flex items-center gap-2">
{/* Gestisci tag */}
<Button
variant="outline"
size="sm"
onClick={() => setShowTagSelector(true)}
title="Gestisci tag"
>
<Tag className="h-4 w-4 mr-1 text-primary" />
Tag
{message.labels && message.labels.length > 0 && (
<span className="ml-1 text-xs bg-primary text-primary-foreground rounded-full px-1.5 py-0.5">
{message.labels.length}
</span>
)}
</Button>
{/* Stella / Preferito */}
<Button
variant="ghost"
@@ -212,6 +279,28 @@ export function MessageDetailPage() {
</div>
</div>
{/* Tag badges */}
{message.labels && message.labels.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{message.labels.map((label) => (
<TagBadge
key={label.id}
label={label}
size="md"
onRemove={() => removeLabelMutation.mutate(label.id)}
/>
))}
<button
type="button"
onClick={() => setShowTagSelector(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm text-muted-foreground border border-dashed border-muted-foreground/40 hover:border-primary hover:text-primary transition-colors"
>
<Tag className="h-3.5 w-3.5" />
Modifica
</button>
</div>
)}
{/* Dettagli busta */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
<div className="grid grid-cols-[auto,1fr] gap-x-4 gap-y-1.5 text-sm">
@@ -291,11 +380,11 @@ export function MessageDetailPage() {
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{attachments.map((att) => (
<a
<button
key={att.id}
href={messagesApi.getAttachmentUrl(message.id, att.id)}
download={att.filename}
className="flex items-center gap-3 rounded-lg border bg-background p-3 hover:bg-muted/50 transition-colors group"
type="button"
onClick={() => handleDownloadAttachment(att)}
className="flex items-center gap-3 rounded-lg border bg-background p-3 hover:bg-muted/50 transition-colors group text-left w-full"
>
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Paperclip className="h-5 w-5 text-primary" />
@@ -307,7 +396,7 @@ export function MessageDetailPage() {
</p>
</div>
<Download className="h-4 w-4 text-muted-foreground group-hover:text-foreground flex-shrink-0" />
</a>
</button>
))}
</div>
</div>
@@ -342,6 +431,18 @@ export function MessageDetailPage() {
)}
</div>
</div>
{/* Dialog gestione tag */}
{showTagSelector && (
<TagSelector
mode="single"
open={showTagSelector}
onClose={() => setShowTagSelector(false)}
currentLabelIds={currentLabelIds}
onApply={(labelIds) => setLabelsMutation.mutate(labelIds)}
isApplying={setLabelsMutation.isPending}
/>
)}
</div>
)
}
+42
View File
@@ -133,6 +133,47 @@ export interface ConnectionTestResult {
capabilities: string[] | null
}
// ─── Label (Tag) ──────────────────────────────────────────────────────────────
export interface LabelResponse {
id: string
tenant_id: string
name: string
color: string | null
}
export interface LabelCreate {
name: string
color?: string | null
}
export interface LabelUpdate {
name?: string
color?: string | null
}
export interface MessageLabelSetRequest {
label_ids: string[]
}
export interface MessageLabelAddRequest {
label_ids: string[]
}
export interface MessageLabelRemoveRequest {
label_ids: string[]
}
export interface MessageBulkLabelRequest {
message_ids: string[]
label_ids: string[]
action: 'add' | 'remove'
}
export interface MessageBulkLabelResponse {
updated: number
}
// ─── Message ──────────────────────────────────────────────────────────────────
export type PecDirection = 'inbound' | 'outbound'
@@ -185,6 +226,7 @@ export interface MessageResponse {
raw_eml_path: string | null
created_at: string
updated_at: string
labels: LabelResponse[]
}
export interface MessageListResponse {