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:
parent
ce84a5435e
commit
e954a8f78e
5 changed files with 294 additions and 26 deletions
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
82
ondeck/src/app/api/admin/shape-import/browse/route.ts
Normal file
82
ondeck/src/app/api/admin/shape-import/browse/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue