From e954a8f78ebe3b5dda171458744ee36c26102418 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 8 Apr 2026 13:34:54 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20SharePoint=20location=20picker=20for=20?= =?UTF-8?q?SHAPE=20import=20(site=20=E2=86=92=20drive=20=E2=86=92=20folder?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ondeck/prisma/schema.prisma | 1 + .../api/admin/shape-import/browse/route.ts | 82 +++++++ .../src/app/api/admin/shape-import/route.ts | 5 +- .../components/admin/shape-import-panel.tsx | 223 ++++++++++++++++-- ondeck/src/lib/shape-import/run-import.ts | 9 +- 5 files changed, 294 insertions(+), 26 deletions(-) create mode 100644 ondeck/src/app/api/admin/shape-import/browse/route.ts diff --git a/ondeck/prisma/schema.prisma b/ondeck/prisma/schema.prisma index e1aafdc..dec11db 100644 --- a/ondeck/prisma/schema.prisma +++ b/ondeck/prisma/schema.prisma @@ -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? diff --git a/ondeck/src/app/api/admin/shape-import/browse/route.ts b/ondeck/src/app/api/admin/shape-import/browse/route.ts new file mode 100644 index 0000000..8224031 --- /dev/null +++ b/ondeck/src/app/api/admin/shape-import/browse/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' + +async function getGraphToken(): Promise { + 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 }) + } +} diff --git a/ondeck/src/app/api/admin/shape-import/route.ts b/ondeck/src/app/api/admin/shape-import/route.ts index e3f630d..efb0266 100644 --- a/ondeck/src/app/api/admin/shape-import/route.ts +++ b/ondeck/src/app/api/admin/shape-import/route.ts @@ -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 }, diff --git a/ondeck/src/components/admin/shape-import-panel.tsx b/ondeck/src/components/admin/shape-import-panel.tsx index eb5d53e..7796d24 100644 --- a/ondeck/src/components/admin/shape-import-panel.tsx +++ b/ondeck/src/components/admin/shape-import-panel.tsx @@ -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(initialRuns) - const [driveId, setDriveId] = useState(DEFAULT_DRIVE_ID) + + // SharePoint picker state + const [sites, setSites] = useState([]) + const [drives, setDrives] = useState([]) + const [folders, setFolders] = useState([]) + const [selectedSiteId, setSelectedSiteId] = useState('') + const [selectedDriveId, setSelectedDriveId] = useState(DEFAULT_DRIVE_ID) + const [selectedFolderId, setSelectedFolderId] = useState('') + const [folderPath, setFolderPath] = useState(DEFAULT_FOLDER_PATH) + const [loadingSites, setLoadingSites] = useState(false) + const [loadingDrives, setLoadingDrives] = useState(false) + const [loadingFolders, setLoadingFolders] = useState(false) + const [pickerError, setPickerError] = useState(null) + const [activeRunId, setActiveRunId] = useState(null) const [activeRunStatus, setActiveRunStatus] = useState('idle') const [logLines, setLogLines] = useState([]) @@ -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 (
@@ -197,31 +283,126 @@ export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) { SHAPE Historical Import - +

Import historical task completion data from SHAPE Excel files on SharePoint into Horizon. Always run a dry run first — it reads SharePoint and shows what would be created/updated without writing anything to the database.

-
- - setDriveId(e.target.value)} - disabled={isRunning} - className="font-mono text-xs" - /> -

- Path: Claims/SHAPE Accounts/ within this drive. Default is the current production drive. -

+ {/* SharePoint location picker */} +
+
+

+ SharePoint Location +

+ {pickerError && ( +

{pickerError}

+ )} +
+ + {/* Site */} +
+ + +
+ + {/* Document library (drive) */} +
+ + +
+ + {/* Folder */} + {folders.length > 0 && ( +
+ + +
+ )} + + {/* Summary line */} +
+

+ Path: {folderPath} +

+ +