feat: SharePoint location picker for SHAPE import (site → drive → folder)

- GET /api/admin/shape-import/browse: lists sites, drives, and folders via Graph API
- ShapeImportPanel: cascading Site / Document Library / Folder selects
  with loading states, error display, and Reset to default
- folderPath stored on ShapeImportRun, passed through to runShapeImport
- discoverFiles accepts dynamic folderPath instead of hardcoded path
- Prisma: added folderPath field to ShapeImportRun model
This commit is contained in:
lorentz 2026-04-08 13:34:54 +00:00
parent ce84a5435e
commit e954a8f78e
5 changed files with 294 additions and 26 deletions

View file

@ -454,6 +454,7 @@ model ShapeImportRun {
status String // pending | running | completed | failed
dryRun Boolean @default(true) @map("dry_run")
driveId String @map("drive_id")
folderPath String @default("Claims/SHAPE Accounts") @map("folder_path")
triggeredBy String? @map("triggered_by")
stats Json?

View file

@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
async function getGraphToken(): Promise<string> {
const { AZURE_AD_TENANT_ID, AZURE_AD_CLIENT_ID, AZURE_AD_CLIENT_SECRET } = process.env
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: AZURE_AD_CLIENT_ID!,
client_secret: AZURE_AD_CLIENT_SECRET!,
scope: 'https://graph.microsoft.com/.default',
})
const res = await fetch(
`https://login.microsoftonline.com/${AZURE_AD_TENANT_ID}/oauth2/v2.0/token`,
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() }
)
if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`)
const data = await res.json()
return data.access_token
}
async function graphGet(token: string, url: string) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error(`Graph ${res.status}: ${(await res.text()).slice(0, 200)}`)
return res.json()
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const roles = (session.user as any)?.roles || []
if (!roles.includes('Admin')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = req.nextUrl
const level = searchParams.get('level') ?? 'sites' // sites | drives | folders
const siteId = searchParams.get('siteId') ?? ''
const driveId = searchParams.get('driveId') ?? ''
const itemId = searchParams.get('itemId') ?? '' // folder item id, empty = root
try {
const token = await getGraphToken()
if (level === 'sites') {
// List all SharePoint sites the app has access to
const data = await graphGet(token, 'https://graph.microsoft.com/v1.0/sites?search=*&$top=50&$select=id,displayName,webUrl')
const sites = (data.value ?? []).map((s: any) => ({
id: s.id,
name: s.displayName,
url: s.webUrl,
}))
return NextResponse.json({ sites })
}
if (level === 'drives') {
if (!siteId) return NextResponse.json({ error: 'siteId required' }, { status: 400 })
const data = await graphGet(token, `https://graph.microsoft.com/v1.0/sites/${siteId}/drives?$select=id,name,driveType`)
const drives = (data.value ?? []).map((d: any) => ({
id: d.id,
name: d.name,
type: d.driveType,
}))
return NextResponse.json({ drives })
}
if (level === 'folders') {
if (!driveId) return NextResponse.json({ error: 'driveId required' }, { status: 400 })
const childrenUrl = itemId
? `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/children?$select=id,name,folder&$top=200`
: `https://graph.microsoft.com/v1.0/drives/${driveId}/root/children?$select=id,name,folder&$top=200`
const data = await graphGet(token, childrenUrl)
const folders = (data.value ?? [])
.filter((i: any) => i.folder)
.map((i: any) => ({ id: i.id, name: i.name }))
return NextResponse.json({ folders })
}
return NextResponse.json({ error: 'Invalid level' }, { status: 400 })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View file

@ -35,10 +35,11 @@ export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}))
const dryRun: boolean = body.dryRun !== false
const driveId: string = body.driveId || DEFAULT_DRIVE_ID
const folderPath: string = body.folderPath || 'Claims/SHAPE Accounts'
const userId = (session.user as any).id as string
const run = await prisma.shapeImportRun.create({
data: { id: cuid(), status: 'running', dryRun, driveId, triggeredBy: userId },
data: { id: cuid(), status: 'running', dryRun, driveId, folderPath, triggeredBy: userId },
})
let seq = 0
@ -52,7 +53,7 @@ export async function POST(req: NextRequest) {
// Run async — don't await so the response returns immediately
;(async () => {
try {
const stats = await runShapeImport({ dryRun, driveId, onLog })
const stats = await runShapeImport({ dryRun, driveId, folderPath, onLog })
await prisma.shapeImportRun.update({
where: { id: run.id },
data: { status: 'completed', completedAt: new Date(), stats: stats as any },

View file

@ -5,8 +5,14 @@ import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
@ -21,12 +27,17 @@ import {
XCircle,
Clock,
FileText,
ChevronDown,
ChevronUp,
FolderOpen,
AlertTriangle,
RefreshCw,
} from 'lucide-react'
const DEFAULT_DRIVE_ID = 'b!OYuzIexQkkOvfEPyMJPzzZHfzTrOCOdPhTWgTlzKs6M0ZWVrAc6LR4LjWl4QFEzm'
const DEFAULT_FOLDER_PATH = 'Claims/SHAPE Accounts'
interface SpSite { id: string; name: string; url: string }
interface SpDrive { id: string; name: string; type: string }
interface SpFolder { id: string; name: string }
interface Run {
id: string
@ -62,7 +73,20 @@ function StatLine({ label, value }: { label: string; value: number | string }) {
export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
const [runs, setRuns] = useState<Run[]>(initialRuns)
const [driveId, setDriveId] = useState(DEFAULT_DRIVE_ID)
// SharePoint picker state
const [sites, setSites] = useState<SpSite[]>([])
const [drives, setDrives] = useState<SpDrive[]>([])
const [folders, setFolders] = useState<SpFolder[]>([])
const [selectedSiteId, setSelectedSiteId] = useState<string>('')
const [selectedDriveId, setSelectedDriveId] = useState<string>(DEFAULT_DRIVE_ID)
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
const [folderPath, setFolderPath] = useState<string>(DEFAULT_FOLDER_PATH)
const [loadingSites, setLoadingSites] = useState(false)
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[]>([])
@ -146,6 +170,67 @@ export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
useEffect(() => () => stopPolling(), [stopPolling])
// Load sites on mount
useEffect(() => {
setLoadingSites(true)
setPickerError(null)
fetch('/api/admin/shape-import/browse?level=sites')
.then((r) => r.json())
.then((d) => { if (d.sites) setSites(d.sites); else setPickerError(d.error ?? 'Failed to load sites') })
.catch((e) => setPickerError(e.message))
.finally(() => setLoadingSites(false))
}, [])
const handleSiteChange = async (siteId: string) => {
setSelectedSiteId(siteId)
setSelectedDriveId('')
setSelectedFolderId('')
setFolderPath(DEFAULT_FOLDER_PATH)
setDrives([])
setFolders([])
setLoadingDrives(true)
setPickerError(null)
try {
const res = await fetch(`/api/admin/shape-import/browse?level=drives&siteId=${encodeURIComponent(siteId)}`)
const d = await res.json()
if (d.drives) setDrives(d.drives)
else setPickerError(d.error ?? 'Failed to load drives')
} catch (e: any) { 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([])
@ -154,11 +239,11 @@ export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
const res = await fetch('/api/admin/shape-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dryRun, driveId }),
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, stats: null }
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)
@ -186,6 +271,7 @@ export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
}
const isRunning = activeRunStatus === 'running' && activeRunId !== null
const canRun = !!selectedDriveId && !isRunning && !starting
return (
<div className="space-y-6">
@ -197,31 +283,126 @@ export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
SHAPE Historical Import
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<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>
<div className="space-y-1.5">
<Label htmlFor="driveId">SharePoint Drive ID</Label>
<Input
id="driveId"
value={driveId}
onChange={(e) => setDriveId(e.target.value)}
disabled={isRunning}
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Path: <code>Claims/SHAPE Accounts/</code> within this drive. Default is the current production drive.
</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>
{/* Site */}
<div className="space-y-1">
<Label className="text-xs">Site</Label>
<Select
value={selectedSiteId}
onValueChange={handleSiteChange}
disabled={isRunning || loadingSites}
>
<SelectTrigger className="h-8 text-sm">
{loadingSites ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> Loading sites
</span>
) : (
<SelectValue placeholder="Select a site…" />
)}
</SelectTrigger>
<SelectContent>
{sites.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Document library (drive) */}
<div className="space-y-1">
<Label className="text-xs">Document Library</Label>
<Select
value={selectedDriveId}
onValueChange={handleDriveChange}
disabled={isRunning || loadingDrives || (!selectedSiteId && drives.length === 0)}
>
<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={selectedSiteId ? 'Select a document library…' : 'Select a site first'} />
)}
</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={() => {
setSelectedSiteId(''); setSelectedDriveId(DEFAULT_DRIVE_ID)
setSelectedFolderId(''); setFolderPath(DEFAULT_FOLDER_PATH)
setDrives([]); 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={isRunning || starting || !driveId}
disabled={!canRun}
variant="outline"
className="gap-2"
>
@ -235,7 +416,7 @@ export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
<Button
onClick={() => setConfirmOpen(true)}
disabled={isRunning || starting || !driveId}
disabled={!canRun}
variant="destructive"
className="gap-2"
>

View file

@ -150,13 +150,15 @@ async function listChildren(driveId: string, itemId: string): Promise<DriveItem[
async function discoverFiles(
driveId: string,
folderPath: string,
log: (line: string) => Promise<void>
): Promise<Array<{ folder: string; itemId: string; fileName: string }>> {
await log('Discovering files from SharePoint...')
const files: Array<{ folder: string; itemId: string; fileName: string }> = []
const encodedPath = folderPath.split('/').map(encodeURIComponent).join('%2F')
const rootData = (await graphGet(
`https://graph.microsoft.com/v1.0/drives/${driveId}/root:/Claims%2FSHAPE%20Accounts:/children`
`https://graph.microsoft.com/v1.0/drives/${driveId}/root:/${encodedPath}:/children`
)) as { value: DriveItem[] }
const rootChildren = rootData.value ?? []
@ -710,9 +712,10 @@ async function processFile(folder: string, itemId: string, fileName: string, dri
export async function runShapeImport(opts: {
dryRun: boolean
driveId: string
folderPath?: string
onLog: (line: string) => Promise<void>
}): Promise<ImportStats> {
const { dryRun, driveId, onLog } = opts
const { dryRun, driveId, folderPath = 'Claims/SHAPE Accounts', onLog } = opts
const stats: ImportStats = {
filesProcessed: 0, clientsMatched: 0, clientsUnmatched: 0,
@ -731,7 +734,7 @@ export async function runShapeImport(opts: {
await fixTemplates(shapeDes.id, shape2Des.id, dryRun, onLog)
const dbState = await loadDbState(onLog)
const files = await discoverFiles(driveId, onLog)
const files = await discoverFiles(driveId, folderPath, onLog)
await onLog(`\nProcessing ${files.length} files...`)
for (let i = 0; i < files.length; i++) {