Timezone (all users EST): - lib/utils: hardcode APP_TIME_ZONE (America/New_York) in formatDate/ formatRenewalDate, add formatDateTime and todayInAppTimeZone helpers - Replace every ad-hoc toLocaleDateString/toLocaleString call across the app (task notes, audit log, backups, shape import, renewal groups, client detail) with the shared EST-aware helpers - Fix UTC "today" bug in date-input defaults/min/max (completion date, reminder date) that rolled to the next calendar day after ~7-8pm ET Task provenance: - Task card info icon (now visible to all users, not just privileged) shows full origin: template, level, renewal anchor, due-date math, and who/what generated the task - Include template.daysOffset and creator in task queries Setup N/A: - New setupNaAt/setupNaReason fields on Client; mark/restore UI and API to exclude non-Shape/lost-business clients from the setup queue everywhere it's counted (queue page, API, manager dashboard, metrics gauge) Task generation fixes: - auto-generate: client-level branch now gated on the renewal anchor's (group/policy) createdAt instead of the client's, so pre-go-live clients with new post-go-live groups are no longer skipped forever - New auto-assign-tasks sweep run after every sync to assign advocates to tasks created by paths that don't assign directly (e.g. setup wizard)
589 lines
23 KiB
TypeScript
589 lines
23 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { formatDateTime } from '@/lib/utils'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
PlayCircle,
|
|
Loader2,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Clock,
|
|
FileText,
|
|
FolderOpen,
|
|
AlertTriangle,
|
|
RefreshCw,
|
|
Zap,
|
|
} from 'lucide-react'
|
|
|
|
const DEFAULT_DRIVE_ID = 'b!OYuzIexQkkOvfEPyMJPzzZHfzTrOCOdPhTWgTlzKs6M0ZWVrAc6LR4LjWl4QFEzm'
|
|
const DEFAULT_FOLDER_PATH = 'Claims/SHAPE Accounts'
|
|
|
|
interface SpDrive { id: string; name: string; type: string }
|
|
interface SpFolder { id: string; name: string }
|
|
|
|
interface Run {
|
|
id: string
|
|
startedAt: string
|
|
completedAt: string | null
|
|
status: string
|
|
dryRun: boolean
|
|
driveId: string
|
|
stats: Record<string, any> | null
|
|
user?: { displayName: string | null; email: string } | null
|
|
}
|
|
|
|
interface LogLine {
|
|
seq: number
|
|
line: string
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
if (status === 'completed') return <Badge className="bg-green-600">Completed</Badge>
|
|
if (status === 'failed') return <Badge variant="destructive">Failed</Badge>
|
|
if (status === 'running') return <Badge className="bg-blue-600 animate-pulse">Running</Badge>
|
|
return <Badge variant="secondary">Pending</Badge>
|
|
}
|
|
|
|
function StatLine({ label, value }: { label: string; value: number | string }) {
|
|
return (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">{label}</span>
|
|
<span className="font-medium tabular-nums">{value}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function formatDate(iso: string) {
|
|
return formatDateTime(iso, { timeZoneName: undefined })
|
|
}
|
|
|
|
export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
|
|
const [mounted, setMounted] = useState(false)
|
|
useEffect(() => setMounted(true), [])
|
|
const [runs, setRuns] = useState<Run[]>(initialRuns)
|
|
|
|
// SharePoint picker state
|
|
const [drives, setDrives] = useState<SpDrive[]>([])
|
|
const [folders, setFolders] = useState<SpFolder[]>([])
|
|
const [selectedDriveId, setSelectedDriveId] = useState<string>(DEFAULT_DRIVE_ID)
|
|
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
|
|
const [folderPath, setFolderPath] = useState<string>(DEFAULT_FOLDER_PATH)
|
|
const [loadingDrives, setLoadingDrives] = useState(false)
|
|
const [loadingFolders, setLoadingFolders] = useState(false)
|
|
const [pickerError, setPickerError] = useState<string | null>(null)
|
|
|
|
const [activeRunId, setActiveRunId] = useState<string | null>(null)
|
|
const [activeRunStatus, setActiveRunStatus] = useState<string>('idle')
|
|
const [logLines, setLogLines] = useState<LogLine[]>([])
|
|
const [lastSeq, setLastSeq] = useState(-1)
|
|
const [viewingRunId, setViewingRunId] = useState<string | null>(null)
|
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
const [starting, setStarting] = useState(false)
|
|
const [gapFillRunning, setGapFillRunning] = useState(false)
|
|
const [gapFillResult, setGapFillResult] = useState<Record<string, any> | null>(null)
|
|
const logRef = useRef<HTMLPreElement>(null)
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
scrollToBottom()
|
|
}, [logLines, scrollToBottom])
|
|
|
|
const stopPolling = useCallback(() => {
|
|
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
|
|
}, [])
|
|
|
|
const pollRun = useCallback(async (runId: string, currentSeq: number) => {
|
|
try {
|
|
const res = await fetch(`/api/admin/shape-import/${runId}?after=${currentSeq}`)
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
|
|
if (data.logs && data.logs.length > 0) {
|
|
setLogLines((prev) => {
|
|
const existing = new Set(prev.map((l) => l.seq))
|
|
const newLines = (data.logs as LogLine[]).filter((l) => !existing.has(l.seq))
|
|
return [...prev, ...newLines]
|
|
})
|
|
const maxSeq = Math.max(...(data.logs as LogLine[]).map((l) => l.seq))
|
|
setLastSeq(maxSeq)
|
|
currentSeq = maxSeq
|
|
}
|
|
|
|
if (data.run?.status === 'completed' || data.run?.status === 'failed') {
|
|
setActiveRunStatus(data.run.status)
|
|
stopPolling()
|
|
setRuns((prev) =>
|
|
prev.map((r) => (r.id === runId ? { ...r, status: data.run.status, completedAt: data.run.completedAt, stats: data.run.stats } : r))
|
|
)
|
|
}
|
|
} catch {
|
|
// transient — keep polling
|
|
}
|
|
}, [stopPolling])
|
|
|
|
const startPolling = useCallback((runId: string) => {
|
|
stopPolling()
|
|
let seq = -1
|
|
pollRef.current = setInterval(async () => {
|
|
try {
|
|
const res = await fetch(`/api/admin/shape-import/${runId}?after=${seq}`)
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
if (data.logs && data.logs.length > 0) {
|
|
setLogLines((prev) => {
|
|
const existing = new Set(prev.map((l) => l.seq))
|
|
const newLines = (data.logs as LogLine[]).filter((l) => !existing.has(l.seq))
|
|
return [...prev, ...newLines]
|
|
})
|
|
seq = Math.max(...(data.logs as LogLine[]).map((l) => l.seq))
|
|
setLastSeq(seq)
|
|
}
|
|
if (data.run?.status === 'completed' || data.run?.status === 'failed') {
|
|
setActiveRunStatus(data.run.status)
|
|
stopPolling()
|
|
setRuns((prev) =>
|
|
prev.map((r) => (r.id === runId ? { ...r, status: data.run.status, completedAt: data.run.completedAt, stats: data.run.stats } : r))
|
|
)
|
|
if (data.run.status === 'completed') toast.success('Import completed')
|
|
else toast.error('Import failed — check the log for details')
|
|
}
|
|
} catch { /* keep polling */ }
|
|
}, 2000)
|
|
}, [stopPolling])
|
|
|
|
useEffect(() => () => stopPolling(), [stopPolling])
|
|
|
|
// Load document libraries on mount (site is fixed to Commercial)
|
|
useEffect(() => {
|
|
setLoadingDrives(true)
|
|
setPickerError(null)
|
|
fetch('/api/admin/shape-import/browse?level=drives')
|
|
.then((r) => r.json())
|
|
.then((d) => { if (d.drives) setDrives(d.drives); else setPickerError(d.error ?? 'Failed to load document libraries') })
|
|
.catch((e) => setPickerError(e.message))
|
|
.finally(() => setLoadingDrives(false))
|
|
}, [])
|
|
|
|
const handleDriveChange = async (driveId: string) => {
|
|
setSelectedDriveId(driveId)
|
|
setSelectedFolderId('')
|
|
setFolderPath(DEFAULT_FOLDER_PATH)
|
|
setFolders([])
|
|
setLoadingFolders(true)
|
|
setPickerError(null)
|
|
try {
|
|
const res = await fetch(`/api/admin/shape-import/browse?level=folders&driveId=${encodeURIComponent(driveId)}`)
|
|
const d = await res.json()
|
|
if (d.folders) setFolders(d.folders)
|
|
else setPickerError(d.error ?? 'Failed to load folders')
|
|
} catch (e: any) { setPickerError(e.message) }
|
|
finally { setLoadingFolders(false) }
|
|
}
|
|
|
|
const handleFolderChange = async (folderId: string) => {
|
|
setSelectedFolderId(folderId)
|
|
const folderName = folders.find((f) => f.id === folderId)?.name ?? ''
|
|
// Build path: drill one level into selected folder
|
|
const newPath = folderName ? folderName : DEFAULT_FOLDER_PATH
|
|
setFolderPath(newPath)
|
|
// Load subfolders so user can go deeper if needed
|
|
setLoadingFolders(true)
|
|
try {
|
|
const res = await fetch(`/api/admin/shape-import/browse?level=folders&driveId=${encodeURIComponent(selectedDriveId)}&itemId=${encodeURIComponent(folderId)}`)
|
|
const d = await res.json()
|
|
if (d.folders && d.folders.length > 0) setFolders(d.folders)
|
|
} catch { /* ignore */ }
|
|
finally { setLoadingFolders(false) }
|
|
}
|
|
|
|
const startRun = async (dryRun: boolean) => {
|
|
setStarting(true)
|
|
setLogLines([])
|
|
setLastSeq(-1)
|
|
try {
|
|
const res = await fetch('/api/admin/shape-import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ dryRun, driveId: selectedDriveId, folderPath }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) throw new Error(data.error || 'Failed to start')
|
|
const newRun: Run = { id: data.runId, startedAt: new Date().toISOString(), completedAt: null, status: 'running', dryRun, driveId: selectedDriveId, stats: null }
|
|
setRuns((prev) => [newRun, ...prev])
|
|
setActiveRunId(data.runId)
|
|
setViewingRunId(data.runId)
|
|
setActiveRunStatus('running')
|
|
startPolling(data.runId)
|
|
toast.info(dryRun ? 'Dry run started — results will appear in the log' : 'Import started')
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to start import')
|
|
} finally {
|
|
setStarting(false)
|
|
}
|
|
}
|
|
|
|
const loadRunLog = async (runId: string) => {
|
|
setViewingRunId(runId)
|
|
setLogLines([])
|
|
setLastSeq(-1)
|
|
try {
|
|
const res = await fetch(`/api/admin/shape-import/${runId}?after=-1`)
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
setLogLines(data.logs || [])
|
|
if (data.logs?.length) setLastSeq(data.logs[data.logs.length - 1].seq)
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const isRunning = activeRunStatus === 'running' && activeRunId !== null
|
|
const canRun = !!selectedDriveId && !isRunning && !starting
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Configuration */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<FileText className="h-4 w-4" />
|
|
SHAPE Historical Import
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-5">
|
|
<p className="text-sm text-muted-foreground">
|
|
Import historical task completion data from SHAPE Excel files on SharePoint into Horizon.
|
|
Always run a <strong>dry run first</strong> — it reads SharePoint and shows what would be
|
|
created/updated without writing anything to the database.
|
|
</p>
|
|
|
|
{/* SharePoint location picker */}
|
|
<div className="space-y-3 p-4 border rounded-lg bg-muted/20">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm font-medium flex items-center gap-1.5">
|
|
<FolderOpen className="h-4 w-4" /> SharePoint Location
|
|
</p>
|
|
{pickerError && (
|
|
<p className="text-xs text-destructive">{pickerError}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
Site: <span className="font-medium text-foreground">seubert365.sharepoint.com/sites/Commercial</span>
|
|
</div>
|
|
|
|
{/* Document library (drive) */}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Document Library</Label>
|
|
<Select
|
|
value={selectedDriveId}
|
|
onValueChange={handleDriveChange}
|
|
disabled={isRunning || loadingDrives}
|
|
>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
{loadingDrives ? (
|
|
<span className="flex items-center gap-2 text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> Loading libraries…
|
|
</span>
|
|
) : (
|
|
<SelectValue placeholder="Select a document library…" />
|
|
)}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{drives.map((d) => (
|
|
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Folder */}
|
|
{folders.length > 0 && (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Folder</Label>
|
|
<Select
|
|
value={selectedFolderId}
|
|
onValueChange={handleFolderChange}
|
|
disabled={isRunning || loadingFolders}
|
|
>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
{loadingFolders ? (
|
|
<span className="flex items-center gap-2 text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> Loading folders…
|
|
</span>
|
|
) : (
|
|
<SelectValue placeholder="Select a folder…" />
|
|
)}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{folders.map((f) => (
|
|
<SelectItem key={f.id} value={f.id}>{f.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Summary line */}
|
|
<div className="flex items-center justify-between pt-1">
|
|
<p className="text-xs text-muted-foreground">
|
|
Path: <code className="bg-background px-1 py-0.5 rounded">{folderPath}</code>
|
|
</p>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 text-xs gap-1"
|
|
onClick={() => {
|
|
setSelectedDriveId(DEFAULT_DRIVE_ID)
|
|
setSelectedFolderId(''); setFolderPath(DEFAULT_FOLDER_PATH)
|
|
setFolders([])
|
|
}}
|
|
disabled={isRunning}
|
|
>
|
|
<RefreshCw className="h-3 w-3" /> Reset to default
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<Button
|
|
onClick={() => startRun(true)}
|
|
disabled={!canRun}
|
|
variant="outline"
|
|
className="gap-2"
|
|
>
|
|
{isRunning && viewingRunId === activeRunId ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<PlayCircle className="h-4 w-4" />
|
|
)}
|
|
Run Dry Run
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={() => setConfirmOpen(true)}
|
|
disabled={!canRun}
|
|
variant="destructive"
|
|
className="gap-2"
|
|
>
|
|
<AlertTriangle className="h-4 w-4" />
|
|
Execute Import
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={async () => {
|
|
setGapFillRunning(true)
|
|
setGapFillResult(null)
|
|
try {
|
|
const res = await fetch('/api/admin/gap-fill', { method: 'POST' })
|
|
const data = await res.json()
|
|
if (!res.ok) throw new Error(data.error || 'Gap fill failed')
|
|
setGapFillResult(data.summary)
|
|
toast.success(`Gap Fill: ${data.summary.totalCreated} tasks created`)
|
|
} catch (err: any) {
|
|
toast.error(err.message)
|
|
} finally {
|
|
setGapFillRunning(false)
|
|
}
|
|
}}
|
|
disabled={gapFillRunning || isRunning}
|
|
variant="outline"
|
|
className="gap-2"
|
|
>
|
|
{gapFillRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
|
|
Gap Fill
|
|
</Button>
|
|
</div>
|
|
|
|
{gapFillResult && (
|
|
<div className="text-sm p-3 rounded-lg bg-muted/40 space-y-1">
|
|
<p className="font-medium">Gap Fill complete</p>
|
|
<p className="text-muted-foreground">Group tasks: {gapFillResult.groupTasksCreated} · Policy tasks: {gapFillResult.policyTasksCreated} · Client tasks: {gapFillResult.clientTasksCreated} · Total: {gapFillResult.totalCreated}</p>
|
|
{gapFillResult.errors > 0 && <p className="text-destructive">{gapFillResult.errors} errors</p>}
|
|
</div>
|
|
)}
|
|
|
|
{isRunning && (
|
|
<div className="flex items-center gap-2 text-sm text-blue-600">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Import running — log updating live below…
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Log viewer */}
|
|
{viewingRunId && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between text-base">
|
|
<span className="flex items-center gap-2">
|
|
<FileText className="h-4 w-4" />
|
|
Import Log
|
|
{isRunning && viewingRunId === activeRunId && (
|
|
<Badge className="bg-blue-600 animate-pulse text-xs">Live</Badge>
|
|
)}
|
|
</span>
|
|
{(() => {
|
|
const run = runs.find((r) => r.id === viewingRunId)
|
|
return run ? <StatusBadge status={run.status} /> : null
|
|
})()}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Stats summary (completed runs) */}
|
|
{(() => {
|
|
const run = runs.find((r) => r.id === viewingRunId)
|
|
if (!run?.stats || run.status === 'running') return null
|
|
const s = run.stats
|
|
return (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 p-3 bg-muted/40 rounded-lg">
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Clients</p>
|
|
<StatLine label="Matched" value={s.clientsMatched ?? 0} />
|
|
<StatLine label="Unmatched" value={s.clientsUnmatched ?? 0} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Tasks</p>
|
|
<StatLine label="Created" value={s.tasksCreated ?? 0} />
|
|
<StatLine label="Updated" value={s.tasksUpdated ?? 0} />
|
|
<StatLine label="Assigned" value={s.tasksAssigned ?? 0} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Other</p>
|
|
<StatLine label="Ad-hoc" value={s.adHocCreated ?? 0} />
|
|
<StatLine label="Duplicates deleted" value={s.duplicatesDeleted ?? 0} />
|
|
<StatLine label="Files" value={s.filesProcessed ?? 0} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Issues</p>
|
|
<StatLine label="Errors" value={(s.errors as any[])?.length ?? 0} />
|
|
<StatLine label="Fuzzy matches" value={(s.fuzzyMatches as any[])?.length ?? 0} />
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{/* Log */}
|
|
<pre
|
|
ref={logRef}
|
|
className="bg-zinc-950 text-zinc-100 text-xs font-mono rounded-lg p-4 overflow-auto max-h-[500px] whitespace-pre-wrap leading-relaxed"
|
|
>
|
|
{logLines.length === 0
|
|
? '(waiting for output...)'
|
|
: logLines.map((l) => l.line).join('\n')}
|
|
</pre>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Run history */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Clock className="h-4 w-4" />
|
|
Run History
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{runs.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-4">No runs yet</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{runs.map((run) => (
|
|
<button
|
|
key={run.id}
|
|
onClick={() => loadRunLog(run.id)}
|
|
className={`w-full text-left p-3 rounded-lg border transition-colors hover:bg-accent ${viewingRunId === run.id ? 'bg-accent border-primary' : ''}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{run.status === 'completed' ? (
|
|
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
|
|
) : run.status === 'failed' ? (
|
|
<XCircle className="h-4 w-4 text-red-600 shrink-0" />
|
|
) : (
|
|
<Loader2 className="h-4 w-4 text-blue-600 animate-spin shrink-0" />
|
|
)}
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">
|
|
{run.dryRun ? 'Dry Run' : 'Execute'} — {mounted ? formatDate(run.startedAt) : ''}
|
|
</p>
|
|
{run.user && (
|
|
<p className="text-xs text-muted-foreground">by {run.user.displayName || run.user.email}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<StatusBadge status={run.status} />
|
|
{run.stats && (
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{(run.stats as any).tasksCreated ?? 0} created · {(run.stats as any).errors?.length ?? 0} errors
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Execute confirmation dialog */}
|
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
|
<AlertTriangle className="h-5 w-5" />
|
|
Execute SHAPE Import
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3 text-sm">
|
|
<p>This will <strong>write data to the database</strong>:</p>
|
|
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
|
<li>Create or update tasks for matched SHAPE clients</li>
|
|
<li>Mark historical tasks as Completed or N/A</li>
|
|
<li>Assign claims advocates to clients (if not already set)</li>
|
|
<li>Delete duplicate tasks</li>
|
|
</ul>
|
|
<p className="text-muted-foreground">
|
|
Run a <strong>dry run first</strong> and review the log if you haven't already.
|
|
This action cannot be automatically undone.
|
|
</p>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>Cancel</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => { setConfirmOpen(false); startRun(false) }}
|
|
>
|
|
Yes, Execute Import
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|