feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
322
frontend/src/components/maintenance/MaintenanceForm.tsx
Normal file
322
frontend/src/components/maintenance/MaintenanceForm.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* MaintenanceForm -- Dialog-based form for creating/editing maintenance windows.
|
||||
*
|
||||
* Supports device multi-select (or "All Devices" checkbox), datetime range,
|
||||
* suppress alerts toggle, and notes.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import {
|
||||
maintenanceApi,
|
||||
devicesApi,
|
||||
type MaintenanceWindow,
|
||||
type MaintenanceWindowCreate,
|
||||
} from '@/lib/api'
|
||||
|
||||
interface MaintenanceFormProps {
|
||||
tenantId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editWindow?: MaintenanceWindow | null
|
||||
}
|
||||
|
||||
export function MaintenanceForm({
|
||||
tenantId,
|
||||
open,
|
||||
onOpenChange,
|
||||
editWindow,
|
||||
}: MaintenanceFormProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const isEdit = !!editWindow
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [startAt, setStartAt] = useState('')
|
||||
const [endAt, setEndAt] = useState('')
|
||||
const [suppressAlerts, setSuppressAlerts] = useState(true)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [allDevices, setAllDevices] = useState(true)
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
||||
|
||||
// Fetch devices for multi-select
|
||||
const { data: deviceData } = useQuery({
|
||||
queryKey: ['devices', tenantId, 'maintenance-form'],
|
||||
queryFn: () => devicesApi.list(tenantId, { page_size: 500 }),
|
||||
enabled: open && !!tenantId,
|
||||
})
|
||||
|
||||
const devices = deviceData?.items ?? []
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (editWindow) {
|
||||
setName(editWindow.name)
|
||||
// Convert ISO to datetime-local format
|
||||
setStartAt(toDatetimeLocal(editWindow.start_at))
|
||||
setEndAt(toDatetimeLocal(editWindow.end_at))
|
||||
setSuppressAlerts(editWindow.suppress_alerts)
|
||||
setNotes(editWindow.notes ?? '')
|
||||
const hasDevices = editWindow.device_ids.length > 0
|
||||
setAllDevices(!hasDevices)
|
||||
setSelectedDevices(hasDevices ? editWindow.device_ids : [])
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}, [editWindow, open])
|
||||
|
||||
function resetForm() {
|
||||
setName('')
|
||||
setStartAt('')
|
||||
setEndAt('')
|
||||
setSuppressAlerts(true)
|
||||
setNotes('')
|
||||
setAllDevices(true)
|
||||
setSelectedDevices([])
|
||||
}
|
||||
|
||||
function toDatetimeLocal(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const offset = d.getTimezoneOffset()
|
||||
const local = new Date(d.getTime() - offset * 60000)
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: MaintenanceWindowCreate) =>
|
||||
maintenanceApi.create(tenantId, data),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Maintenance window created' })
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenance-windows', tenantId] })
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to create maintenance window', variant: 'destructive' })
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: MaintenanceWindowCreate) =>
|
||||
maintenanceApi.update(tenantId, editWindow!.id, data),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Maintenance window updated' })
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenance-windows', tenantId] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to update maintenance window', variant: 'destructive' })
|
||||
},
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
// Validation
|
||||
if (!name.trim()) {
|
||||
toast({ title: 'Name is required', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (!startAt || !endAt) {
|
||||
toast({ title: 'Start and end times are required', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (new Date(endAt) <= new Date(startAt)) {
|
||||
toast({ title: 'End time must be after start time', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (!allDevices && selectedDevices.length === 0) {
|
||||
toast({
|
||||
title: 'Select at least one device or choose "All Devices"',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const data: MaintenanceWindowCreate = {
|
||||
name: name.trim(),
|
||||
device_ids: allDevices ? [] : selectedDevices,
|
||||
start_at: new Date(startAt).toISOString(),
|
||||
end_at: new Date(endAt).toISOString(),
|
||||
suppress_alerts: suppressAlerts,
|
||||
notes: notes.trim() || undefined,
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(data)
|
||||
} else {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDevice(deviceId: string) {
|
||||
setSelectedDevices((prev) =>
|
||||
prev.includes(deviceId)
|
||||
? prev.filter((id) => id !== deviceId)
|
||||
: [...prev, deviceId],
|
||||
)
|
||||
}
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? 'Edit Maintenance Window' : 'New Maintenance Window'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Schedule a maintenance window to suppress alerts during planned downtime.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mw-name">Name</Label>
|
||||
<Input
|
||||
id="mw-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Firmware upgrade - Branch offices"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start / End */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mw-start">Start</Label>
|
||||
<Input
|
||||
id="mw-start"
|
||||
type="datetime-local"
|
||||
value={startAt}
|
||||
onChange={(e) => setStartAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mw-end">End</Label>
|
||||
<Input
|
||||
id="mw-end"
|
||||
type="datetime-local"
|
||||
value={endAt}
|
||||
onChange={(e) => setEndAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suppress Alerts */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="mw-suppress"
|
||||
checked={suppressAlerts}
|
||||
onCheckedChange={(checked) => setSuppressAlerts(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="mw-suppress" className="cursor-pointer">
|
||||
Suppress alerts during this window
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Device Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Devices</Label>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Checkbox
|
||||
id="mw-all-devices"
|
||||
checked={allDevices}
|
||||
onCheckedChange={(checked) => {
|
||||
setAllDevices(checked === true)
|
||||
if (checked) setSelectedDevices([])
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="mw-all-devices" className="cursor-pointer">
|
||||
All devices in tenant
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!allDevices && (
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-border bg-elevated/30 p-2 space-y-1">
|
||||
{devices.length === 0 ? (
|
||||
<p className="text-xs text-text-muted py-2 text-center">
|
||||
No devices found
|
||||
</p>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="flex items-center gap-2 px-1 py-0.5 rounded hover:bg-elevated/50"
|
||||
>
|
||||
<Checkbox
|
||||
id={`dev-${device.id}`}
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onCheckedChange={() => toggleDevice(device.id)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`dev-${device.id}`}
|
||||
className="cursor-pointer flex-1 text-xs"
|
||||
>
|
||||
{device.hostname}{' '}
|
||||
<span className="text-text-muted">({device.ip_address})</span>
|
||||
</Label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!allDevices && selectedDevices.length > 0 && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{selectedDevices.length} device{selectedDevices.length !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mw-notes">Notes (optional)</Label>
|
||||
<textarea
|
||||
id="mw-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Reason for maintenance, ticket number, etc."
|
||||
rows={2}
|
||||
className="w-full rounded-md border border-border bg-elevated/50 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-ring resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading
|
||||
? isEdit
|
||||
? 'Updating...'
|
||||
: 'Creating...'
|
||||
: isEdit
|
||||
? 'Update Window'
|
||||
: 'Create Window'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
342
frontend/src/components/maintenance/MaintenanceList.tsx
Normal file
342
frontend/src/components/maintenance/MaintenanceList.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* MaintenanceList -- Three-section layout showing active, upcoming, and past
|
||||
* maintenance windows with create/edit/delete actions.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Calendar,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
BellOff,
|
||||
Bell,
|
||||
Monitor,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { maintenanceApi, type MaintenanceWindow } from '@/lib/api'
|
||||
import { MaintenanceForm } from './MaintenanceForm'
|
||||
|
||||
interface MaintenanceListProps {
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
export function MaintenanceList({ tenantId }: MaintenanceListProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editWindow, setEditWindow] = useState<MaintenanceWindow | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<MaintenanceWindow | null>(null)
|
||||
|
||||
const { data: windows, isLoading } = useQuery({
|
||||
queryKey: ['maintenance-windows', tenantId],
|
||||
queryFn: () => maintenanceApi.list(tenantId),
|
||||
enabled: !!tenantId,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (windowId: string) => maintenanceApi.delete(tenantId, windowId),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Maintenance window deleted' })
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenance-windows', tenantId] })
|
||||
setDeleteTarget(null)
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to delete maintenance window', variant: 'destructive' })
|
||||
},
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const active = (windows ?? []).filter(
|
||||
(w) => new Date(w.start_at) <= now && new Date(w.end_at) >= now,
|
||||
)
|
||||
const upcoming = (windows ?? []).filter((w) => new Date(w.start_at) > now)
|
||||
const past = (windows ?? [])
|
||||
.filter((w) => new Date(w.end_at) < now)
|
||||
.slice(0, 20)
|
||||
|
||||
function openEdit(w: MaintenanceWindow) {
|
||||
setEditWindow(w)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditWindow(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
function formatRange(startAt: string, endAt: string): string {
|
||||
const start = new Date(startAt)
|
||||
const end = new Date(endAt)
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}
|
||||
return `${start.toLocaleDateString(undefined, opts)} - ${end.toLocaleDateString(undefined, opts)}`
|
||||
}
|
||||
|
||||
function formatDuration(startAt: string, endAt: string): string {
|
||||
const ms = new Date(endAt).getTime() - new Date(startAt).getTime()
|
||||
const hours = Math.floor(ms / 3600000)
|
||||
const mins = Math.floor((ms % 3600000) / 60000)
|
||||
if (hours > 0) return `${hours}h ${mins}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-20 rounded-lg border border-border bg-surface animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-text-muted">
|
||||
{(windows ?? []).length} maintenance window{(windows ?? []).length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Window
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Active */}
|
||||
{active.length > 0 && (
|
||||
<Section title="Active" count={active.length} color="border-l-success">
|
||||
{active.map((w) => (
|
||||
<WindowCard
|
||||
key={w.id}
|
||||
window={w}
|
||||
variant="active"
|
||||
onEdit={() => openEdit(w)}
|
||||
onDelete={() => setDeleteTarget(w)}
|
||||
formatRange={formatRange}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Upcoming */}
|
||||
{upcoming.length > 0 && (
|
||||
<Section title="Upcoming" count={upcoming.length} color="border-l-warning">
|
||||
{upcoming.map((w) => (
|
||||
<WindowCard
|
||||
key={w.id}
|
||||
window={w}
|
||||
variant="upcoming"
|
||||
onEdit={() => openEdit(w)}
|
||||
onDelete={() => setDeleteTarget(w)}
|
||||
formatRange={formatRange}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Past */}
|
||||
{past.length > 0 && (
|
||||
<Section title="Past" count={past.length} color="border-l-border">
|
||||
{past.map((w) => (
|
||||
<WindowCard
|
||||
key={w.id}
|
||||
window={w}
|
||||
variant="past"
|
||||
onEdit={() => openEdit(w)}
|
||||
onDelete={() => setDeleteTarget(w)}
|
||||
formatRange={formatRange}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{(windows ?? []).length === 0 && (
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title="No maintenance windows"
|
||||
description="Schedule maintenance windows to suppress alerts during planned work."
|
||||
action={{ label: 'Create Window', onClick: openCreate }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Form dialog */}
|
||||
<MaintenanceForm
|
||||
tenantId={tenantId}
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
editWindow={editWindow}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Maintenance Window</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sub-components ─────────────────────────────────────────────────────────
|
||||
|
||||
function Section({
|
||||
title,
|
||||
count,
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
count: number
|
||||
color: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-2">
|
||||
{title}{' '}
|
||||
<span className="text-text-muted/60">({count})</span>
|
||||
</h3>
|
||||
<div className={`space-y-2 border-l-2 ${color} pl-3`}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WindowCard({
|
||||
window: w,
|
||||
variant,
|
||||
onEdit,
|
||||
onDelete,
|
||||
formatRange,
|
||||
formatDuration,
|
||||
}: {
|
||||
window: MaintenanceWindow
|
||||
variant: 'active' | 'upcoming' | 'past'
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
formatRange: (s: string, e: string) => string
|
||||
formatDuration: (s: string, e: string) => string
|
||||
}) {
|
||||
const isPast = variant === 'past'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-border bg-surface p-3 ${
|
||||
isPast ? 'opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{variant === 'active' && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-success" />
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-medium text-text-primary truncate">
|
||||
{w.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted mb-1.5">
|
||||
{formatRange(w.start_at, w.end_at)}{' '}
|
||||
<span className="text-text-muted/60">
|
||||
({formatDuration(w.start_at, w.end_at)})
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Device count */}
|
||||
<Badge variant="outline" className="text-[10px] gap-1">
|
||||
<Monitor className="h-3 w-3" />
|
||||
{w.device_ids.length === 0
|
||||
? 'All Devices'
|
||||
: `${w.device_ids.length} device${w.device_ids.length !== 1 ? 's' : ''}`}
|
||||
</Badge>
|
||||
|
||||
{/* Suppress status */}
|
||||
{w.suppress_alerts ? (
|
||||
<Badge variant="outline" className="text-[10px] gap-1 text-warning">
|
||||
<BellOff className="h-3 w-3" />
|
||||
Alerts Suppressed
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] gap-1 text-text-muted">
|
||||
<Bell className="h-3 w-3" />
|
||||
Alerts Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{w.notes && (
|
||||
<p className="text-xs text-text-muted mt-1.5 line-clamp-1">{w.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 rounded-md text-text-muted hover:text-text-primary hover:bg-elevated/50 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1.5 rounded-md text-text-muted hover:text-error hover:bg-error/10 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user