feat(worksheet-parsing): add parsing UI and fix parent access control
Worksheet Parsing UI (Slices 1-2): - Add parse button to OfflineWorkSection thumbnails and PhotoViewerEditor - Create ParsedProblemsList component to display extracted problems - Add useWorksheetParsing hook with mutations for parse/review/approve - Add attachmentKeys to queryKeys for cache management - Wire up parsing workflow in SummaryClient Fix parent upload access: - Change /api/players/[id]/access to use getDbUserId() instead of getViewerId() - Guest users' guestId was not matching parent_child.parent_user_id - Parents can now see upload/camera buttons in offline work section Fix curriculum type errors: - Add missing 'advanced' property to createFullSkillSet() - Fix enabledRequiredSkills -> enabledAllowedSkills in problem-generator - Remove incorrect Partial<> wrapper from type casts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5a4c751ebe
commit
91aaddbeab
|
|
@ -21,10 +21,7 @@ import {
|
|||
} from '@/db/schema/session-plans'
|
||||
import { canPerformAction } from '@/lib/classroom'
|
||||
import { getDbUserId } from '@/lib/viewer'
|
||||
import {
|
||||
convertToSlotResults,
|
||||
computeParsingStats,
|
||||
} from '@/lib/worksheet-parsing'
|
||||
import { convertToSlotResults, computeParsingStats } from '@/lib/worksheet-parsing'
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ playerId: string; attachmentId: string }>
|
||||
|
|
@ -65,18 +62,24 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||
|
||||
// Check if already created a session
|
||||
if (attachment.sessionCreated) {
|
||||
return NextResponse.json({
|
||||
error: 'Session already created from this attachment',
|
||||
sessionId: attachment.createdSessionId,
|
||||
}, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Session already created from this attachment',
|
||||
sessionId: attachment.createdSessionId,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get the parsing result to convert (prefer approved result, fall back to raw)
|
||||
const parsingResult = attachment.approvedResult ?? attachment.rawParsingResult
|
||||
if (!parsingResult) {
|
||||
return NextResponse.json({
|
||||
error: 'No parsing results available. Parse the worksheet first.',
|
||||
}, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'No parsing results available. Parse the worksheet first.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Convert to slot results
|
||||
|
|
@ -86,9 +89,12 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||
})
|
||||
|
||||
if (conversionResult.slotResults.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'No valid problems to create session from',
|
||||
}, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'No valid problems to create session from',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create the session with completed status
|
||||
|
|
|
|||
|
|
@ -119,7 +119,8 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||
usage: result.usage,
|
||||
})
|
||||
} catch (parseError) {
|
||||
const errorMessage = parseError instanceof Error ? parseError.message : 'Unknown parsing error'
|
||||
const errorMessage =
|
||||
parseError instanceof Error ? parseError.message : 'Unknown parsing error'
|
||||
console.error('Worksheet parsing error:', parseError)
|
||||
|
||||
// Update status to failed
|
||||
|
|
@ -131,11 +132,14 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||
})
|
||||
.where(eq(practiceAttachments.id, attachmentId))
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
}, { status: 500 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting parse:', error)
|
||||
|
|
|
|||
|
|
@ -53,10 +53,13 @@ export async function PATCH(request: Request, { params }: RouteParams) {
|
|||
const body = await request.json()
|
||||
const parseResult = ReviewRequestSchema.safeParse(body)
|
||||
if (!parseResult.success) {
|
||||
return NextResponse.json({
|
||||
error: 'Invalid request body',
|
||||
details: parseResult.error.issues,
|
||||
}, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid request body',
|
||||
details: parseResult.error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { corrections, markAsReviewed } = parseResult.data
|
||||
|
|
@ -78,9 +81,12 @@ export async function PATCH(request: Request, { params }: RouteParams) {
|
|||
|
||||
// Check if we have parsing results to correct
|
||||
if (!attachment.rawParsingResult) {
|
||||
return NextResponse.json({
|
||||
error: 'No parsing results to correct. Parse the worksheet first.',
|
||||
}, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'No parsing results to correct. Parse the worksheet first.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Apply corrections to the raw result
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { join } from 'path'
|
|||
import { and, eq } from 'drizzle-orm'
|
||||
import { db } from '@/db'
|
||||
import { practiceAttachments, sessionPlans } from '@/db/schema'
|
||||
import { canPerformAction } from '@/lib/classroom'
|
||||
import { getPlayerAccess, generateAuthorizationError } from '@/lib/classroom'
|
||||
import { getDbUserId } from '@/lib/viewer'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
|
||||
|
|
@ -37,6 +37,16 @@ export interface SessionAttachment {
|
|||
originalUrl: string | null
|
||||
corners: Array<{ x: number; y: number }> | null
|
||||
rotation: 0 | 90 | 180 | 270
|
||||
// Parsing fields
|
||||
parsingStatus: string | null
|
||||
parsedAt: string | null
|
||||
parsingError: string | null
|
||||
rawParsingResult: unknown | null
|
||||
approvedResult: unknown | null
|
||||
confidenceScore: number | null
|
||||
needsReview: boolean
|
||||
sessionCreated: boolean
|
||||
createdSessionId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -52,9 +62,12 @@ export async function GET(_request: Request, { params }: RouteParams) {
|
|||
|
||||
// Authorization check
|
||||
const userId = await getDbUserId()
|
||||
const canView = await canPerformAction(userId, playerId, 'view')
|
||||
if (!canView) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
const access = await getPlayerAccess(userId, playerId)
|
||||
if (access.accessLevel === 'none') {
|
||||
const authError = generateAuthorizationError(access, 'view', {
|
||||
actionDescription: 'view attachments for this student',
|
||||
})
|
||||
return NextResponse.json(authError, { status: 403 })
|
||||
}
|
||||
|
||||
// Get attachments for this session
|
||||
|
|
@ -84,6 +97,16 @@ export async function GET(_request: Request, { params }: RouteParams) {
|
|||
: null,
|
||||
corners: att.corners ?? null,
|
||||
rotation: (att.rotation ?? 0) as 0 | 90 | 180 | 270,
|
||||
// Parsing fields
|
||||
parsingStatus: att.parsingStatus ?? null,
|
||||
parsedAt: att.parsedAt ?? null,
|
||||
parsingError: att.parsingError ?? null,
|
||||
rawParsingResult: att.rawParsingResult ?? null,
|
||||
approvedResult: att.approvedResult ?? null,
|
||||
confidenceScore: att.confidenceScore ?? null,
|
||||
needsReview: att.needsReview === true,
|
||||
sessionCreated: att.sessionCreated === true,
|
||||
createdSessionId: att.createdSessionId ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ attachments: result })
|
||||
|
|
@ -104,11 +127,18 @@ export async function POST(request: Request, { params }: RouteParams) {
|
|||
return NextResponse.json({ error: 'Player ID and Session ID required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Authorization check - require 'start-session' permission (parent or present teacher)
|
||||
// Authorization check - require 'view' permission (parent, teacher-enrolled, or teacher-present)
|
||||
// Adding photos to an existing session is less sensitive than starting a new session
|
||||
const userId = await getDbUserId()
|
||||
const canAdd = await canPerformAction(userId, playerId, 'start-session')
|
||||
if (!canAdd) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
const access = await getPlayerAccess(userId, playerId)
|
||||
if (access.accessLevel === 'none') {
|
||||
console.error(
|
||||
`[attachments POST] Authorization failed: userId=${userId} has no access to playerId=${playerId}`
|
||||
)
|
||||
const authError = generateAuthorizationError(access, 'view', {
|
||||
actionDescription: 'add photos for this student',
|
||||
})
|
||||
return NextResponse.json(authError, { status: 403 })
|
||||
}
|
||||
|
||||
// Verify session exists and belongs to player
|
||||
|
|
@ -257,6 +287,16 @@ export async function POST(request: Request, { params }: RouteParams) {
|
|||
: null,
|
||||
corners,
|
||||
rotation,
|
||||
// New attachments have no parsing data yet
|
||||
parsingStatus: null,
|
||||
parsedAt: null,
|
||||
parsingError: null,
|
||||
rawParsingResult: null,
|
||||
approvedResult: null,
|
||||
confidenceScore: null,
|
||||
needsReview: false,
|
||||
sessionCreated: false,
|
||||
createdSessionId: null,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getPlayerAccess } from '@/lib/classroom'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getDbUserId } from '@/lib/viewer'
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -10,12 +10,14 @@ interface RouteParams {
|
|||
* GET /api/players/[id]/access
|
||||
* Check access level for specific player
|
||||
*
|
||||
* Returns: { accessLevel, isParent, isTeacher, isPresent }
|
||||
* Returns: { accessLevel, isParent, isTeacher, isPresent, classroomId? }
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { id: playerId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
// Use getDbUserId() to get the database user.id, not the guestId
|
||||
// This is required because parent_child links to user.id
|
||||
const viewerId = await getDbUserId()
|
||||
|
||||
const access = await getPlayerAccess(viewerId, playerId)
|
||||
|
||||
|
|
@ -24,6 +26,7 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
|
|||
isParent: access.isParent,
|
||||
isTeacher: access.isTeacher,
|
||||
isPresent: access.isPresent,
|
||||
classroomId: access.classroomId,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to check player access:', error)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
AllProblemsSection,
|
||||
ContentBannerSlot,
|
||||
OfflineWorkSection,
|
||||
type OfflineAttachment,
|
||||
PhotoViewerEditor,
|
||||
type PhotoViewerEditorPhoto,
|
||||
PracticeSubNav,
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
SkillsPanel,
|
||||
StartPracticeModal,
|
||||
} from '@/components/practice'
|
||||
import type { ParsingStatus } from '@/db/schema/practice-attachments'
|
||||
import { calculateAutoPauseInfo } from '@/components/practice/autoPauseCalculator'
|
||||
import { DocumentAdjuster } from '@/components/practice/DocumentAdjuster'
|
||||
import { useDocumentDetection } from '@/components/practice/useDocumentDetection'
|
||||
|
|
@ -36,9 +38,12 @@ import { VisualDebugProvider } from '@/contexts/VisualDebugContext'
|
|||
import type { Player } from '@/db/schema/players'
|
||||
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
|
||||
import { useSessionMode } from '@/hooks/useSessionMode'
|
||||
import { usePlayerAccess, canUploadPhotos } from '@/hooks/usePlayerAccess'
|
||||
import { useStartParsing } from '@/hooks/useWorksheetParsing'
|
||||
import { computeBktFromHistory, type SkillBktResult } from '@/lib/curriculum/bkt'
|
||||
import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner'
|
||||
import { api } from '@/lib/queryClient'
|
||||
import { attachmentKeys } from '@/lib/queryKeys'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
// Combined height of sticky elements above content area
|
||||
|
|
@ -130,29 +135,55 @@ export function SummaryClient({
|
|||
// Session mode - single source of truth for session planning decisions
|
||||
const { data: sessionMode, isLoading: isLoadingSessionMode } = useSessionMode(studentId)
|
||||
|
||||
// Player access - pre-flight authorization check for upload capability
|
||||
const { data: playerAccess } = usePlayerAccess(studentId)
|
||||
const canUpload = canUploadPhotos(playerAccess)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch attachments for this session
|
||||
// Type for session attachment from API
|
||||
interface SessionAttachmentResponse {
|
||||
id: string
|
||||
url: string
|
||||
originalUrl: string | null
|
||||
corners: Array<{ x: number; y: number }> | null
|
||||
rotation: 0 | 90 | 180 | 270
|
||||
// Parsing fields
|
||||
parsingStatus: string | null
|
||||
parsedAt: string | null
|
||||
parsingError: string | null
|
||||
rawParsingResult: object | null
|
||||
approvedResult: object | null
|
||||
confidenceScore: number | null
|
||||
needsReview: boolean
|
||||
sessionCreated: boolean
|
||||
createdSessionId: string | null
|
||||
}
|
||||
|
||||
// Fetch attachments for this session (includes parsing data)
|
||||
const { data: attachmentsData } = useQuery({
|
||||
queryKey: ['session-attachments', studentId, session?.id],
|
||||
queryFn: async () => {
|
||||
queryKey: session?.id ? attachmentKeys.session(studentId, session.id) : ['no-session'],
|
||||
queryFn: async (): Promise<{ attachments: SessionAttachmentResponse[] }> => {
|
||||
if (!session?.id) return { attachments: [] }
|
||||
const res = await api(`curriculum/${studentId}/sessions/${session.id}/attachments`)
|
||||
if (!res.ok) return { attachments: [] }
|
||||
return res.json() as Promise<{
|
||||
attachments: Array<{
|
||||
id: string
|
||||
url: string
|
||||
originalUrl: string | null
|
||||
corners: Array<{ x: number; y: number }> | null
|
||||
rotation: 0 | 90 | 180 | 270
|
||||
}>
|
||||
}>
|
||||
return res.json() as Promise<{ attachments: SessionAttachmentResponse[] }>
|
||||
},
|
||||
enabled: !!session?.id,
|
||||
})
|
||||
|
||||
const attachments = attachmentsData?.attachments ?? []
|
||||
// Map API response to OfflineAttachment type (cast parsingStatus and rawParsingResult)
|
||||
const attachments: OfflineAttachment[] = (attachmentsData?.attachments ?? []).map((att) => ({
|
||||
id: att.id,
|
||||
url: att.url,
|
||||
parsingStatus: att.parsingStatus as ParsingStatus | null,
|
||||
rawParsingResult: att.rawParsingResult as OfflineAttachment['rawParsingResult'],
|
||||
needsReview: att.needsReview,
|
||||
sessionCreated: att.sessionCreated,
|
||||
}))
|
||||
|
||||
// Worksheet parsing mutation
|
||||
const startParsing = useStartParsing(studentId, session?.id ?? '')
|
||||
const hasPhotos = attachments.length > 0
|
||||
|
||||
const isInProgress = session?.startedAt && !session?.completedAt
|
||||
|
|
@ -595,8 +626,13 @@ export function SummaryClient({
|
|||
isUploading={isUploading}
|
||||
uploadError={uploadError}
|
||||
deletingId={deletingId}
|
||||
parsingId={startParsing.isPending ? (startParsing.variables ?? null) : null}
|
||||
dragOver={dragOver}
|
||||
isDark={isDark}
|
||||
canUpload={canUpload}
|
||||
studentId={studentId}
|
||||
studentName={player.name}
|
||||
classroomId={playerAccess?.classroomId}
|
||||
onFileSelect={handleFileSelect}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
|
|
@ -604,6 +640,7 @@ export function SummaryClient({
|
|||
onOpenCamera={() => setShowCamera(true)}
|
||||
onOpenViewer={openViewer}
|
||||
onDeletePhoto={deletePhoto}
|
||||
onParse={(attachmentId) => startParsing.mutate(attachmentId)}
|
||||
/>
|
||||
{/* All Problems - complete session listing */}
|
||||
{hasProblems && (
|
||||
|
|
@ -690,18 +727,24 @@ export function SummaryClient({
|
|||
|
||||
{/* Photo Viewer/Editor */}
|
||||
<PhotoViewerEditor
|
||||
photos={attachments.map((att) => ({
|
||||
photos={(attachmentsData?.attachments ?? []).map((att) => ({
|
||||
id: att.id,
|
||||
url: att.url,
|
||||
originalUrl: att.originalUrl,
|
||||
corners: att.corners,
|
||||
rotation: att.rotation,
|
||||
parsingStatus: att.parsingStatus as PhotoViewerEditorPhoto['parsingStatus'],
|
||||
problemCount: (att.rawParsingResult as { problems?: unknown[] } | null)?.problems
|
||||
?.length,
|
||||
sessionCreated: att.sessionCreated,
|
||||
}))}
|
||||
initialIndex={viewerIndex}
|
||||
initialMode={viewerMode}
|
||||
isOpen={viewerOpen}
|
||||
onClose={() => setViewerOpen(false)}
|
||||
onEditConfirm={handlePhotoEditConfirm}
|
||||
onParse={(attachmentId) => startParsing.mutate(attachmentId)}
|
||||
parsingPhotoId={startParsing.isPending ? (startParsing.variables ?? null) : null}
|
||||
/>
|
||||
|
||||
{/* Fullscreen Camera Modal */}
|
||||
|
|
|
|||
|
|
@ -485,10 +485,7 @@ export function SessionObserverView({
|
|||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding:
|
||||
variant === 'page'
|
||||
? { base: '12px', md: '28px' }
|
||||
: { base: '12px', md: '24px' },
|
||||
padding: variant === 'page' ? { base: '12px', md: '28px' } : { base: '12px', md: '24px' },
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
|
|
|||
|
|
@ -1,12 +1,23 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import type { ParsingStatus } from '@/db/schema/practice-attachments'
|
||||
import type { WorksheetParsingResult } from '@/lib/worksheet-parsing'
|
||||
import { api } from '@/lib/queryClient'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { ParsedProblemsList } from '../worksheet-parsing'
|
||||
|
||||
export interface OfflineAttachment {
|
||||
id: string
|
||||
url: string
|
||||
filename?: string
|
||||
// Parsing fields
|
||||
parsingStatus?: ParsingStatus | null
|
||||
rawParsingResult?: WorksheetParsingResult | null
|
||||
needsReview?: boolean
|
||||
sessionCreated?: boolean
|
||||
}
|
||||
|
||||
export interface OfflineWorkSectionProps {
|
||||
|
|
@ -20,10 +31,20 @@ export interface OfflineWorkSectionProps {
|
|||
uploadError: string | null
|
||||
/** ID of photo being deleted */
|
||||
deletingId: string | null
|
||||
/** ID of photo currently being parsed */
|
||||
parsingId: string | null
|
||||
/** Whether drag is over the drop zone */
|
||||
dragOver: boolean
|
||||
/** Dark mode */
|
||||
isDark: boolean
|
||||
/** Whether the user can upload photos (pre-flight auth check) */
|
||||
canUpload?: boolean
|
||||
/** Student ID for entry prompt */
|
||||
studentId?: string
|
||||
/** Student name for remediation message */
|
||||
studentName?: string
|
||||
/** Classroom ID for entry prompt (when canUpload is false) */
|
||||
classroomId?: string
|
||||
/** Handlers */
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
|
|
@ -33,6 +54,8 @@ export interface OfflineWorkSectionProps {
|
|||
/** Open photo viewer/editor at index with specified mode */
|
||||
onOpenViewer: (index: number, mode: 'view' | 'edit') => void
|
||||
onDeletePhoto: (id: string) => void
|
||||
/** Start parsing a worksheet photo */
|
||||
onParse?: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,8 +73,13 @@ export function OfflineWorkSection({
|
|||
isUploading,
|
||||
uploadError,
|
||||
deletingId,
|
||||
parsingId,
|
||||
dragOver,
|
||||
isDark,
|
||||
canUpload = true,
|
||||
studentId,
|
||||
studentName,
|
||||
classroomId,
|
||||
onFileSelect,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
|
|
@ -59,10 +87,57 @@ export function OfflineWorkSection({
|
|||
onOpenCamera,
|
||||
onOpenViewer,
|
||||
onDeletePhoto,
|
||||
onParse,
|
||||
}: OfflineWorkSectionProps) {
|
||||
const photoCount = attachments.length
|
||||
// Show add tile unless we have 8+ photos (max reasonable gallery size)
|
||||
const showAddTile = photoCount < 8
|
||||
// Also only show if user can upload
|
||||
const showAddTile = photoCount < 8 && canUpload
|
||||
// Show remediation when user can't upload but is a teacher with enrolled student
|
||||
const showRemediation = !canUpload && classroomId && studentId
|
||||
|
||||
// Entry prompt state (for teachers who need student to enter classroom)
|
||||
const [promptSent, setPromptSent] = useState(false)
|
||||
|
||||
// Mutation for sending entry prompt
|
||||
const sendEntryPrompt = useMutation({
|
||||
mutationFn: async (playerId: string) => {
|
||||
if (!classroomId) throw new Error('No classroom ID')
|
||||
const response = await api(`classrooms/${classroomId}/entry-prompts`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ playerIds: [playerId] }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to send prompt')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.created > 0) {
|
||||
setPromptSent(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleSendEntryPrompt = useCallback(() => {
|
||||
if (studentId) {
|
||||
sendEntryPrompt.mutate(studentId)
|
||||
}
|
||||
}, [sendEntryPrompt, studentId])
|
||||
|
||||
// Find all attachments with parsing results
|
||||
const parsedAttachments = attachments.filter(
|
||||
(att) =>
|
||||
att.rawParsingResult?.problems &&
|
||||
att.rawParsingResult.problems.length > 0 &&
|
||||
(att.parsingStatus === 'needs_review' || att.parsingStatus === 'approved')
|
||||
)
|
||||
|
||||
// Track which parsed result is currently expanded (default to first one)
|
||||
const [expandedResultId, setExpandedResultId] = useState<string | null>(
|
||||
parsedAttachments[0]?.id ?? null
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -294,6 +369,115 @@ export function OfflineWorkSection({
|
|||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Parse button - show if not parsed yet OR if failed (to allow retry) */}
|
||||
{onParse &&
|
||||
(!att.parsingStatus || att.parsingStatus === 'failed') &&
|
||||
!att.sessionCreated && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="parse-worksheet"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onParse(att.id)
|
||||
}}
|
||||
disabled={parsingId === att.id}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '0.5rem',
|
||||
right: '0.5rem',
|
||||
height: '24px',
|
||||
paddingX: '0.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.25rem',
|
||||
backgroundColor: att.parsingStatus === 'failed' ? 'orange.500' : 'blue.500',
|
||||
color: 'white',
|
||||
borderRadius: 'full',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: '600',
|
||||
transition: 'background-color 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: att.parsingStatus === 'failed' ? 'orange.600' : 'blue.600',
|
||||
},
|
||||
_disabled: {
|
||||
backgroundColor: 'gray.400',
|
||||
cursor: 'wait',
|
||||
},
|
||||
})}
|
||||
aria-label={att.parsingStatus === 'failed' ? 'Retry parsing' : 'Parse worksheet'}
|
||||
>
|
||||
{parsingId === att.id ? '⏳' : att.parsingStatus === 'failed' ? '🔄' : '🔍'}{' '}
|
||||
{att.parsingStatus === 'failed' ? 'Retry' : 'Parse'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Parsing status badge - don't show for 'failed' since retry button is shown instead */}
|
||||
{att.parsingStatus && att.parsingStatus !== 'failed' && (
|
||||
<div
|
||||
data-element="parsing-status"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '0.5rem',
|
||||
right: '0.5rem',
|
||||
height: '24px',
|
||||
paddingX: '0.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
borderRadius: 'full',
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: '600',
|
||||
backgroundColor:
|
||||
att.parsingStatus === 'processing'
|
||||
? 'blue.500'
|
||||
: att.parsingStatus === 'needs_review'
|
||||
? 'yellow.500'
|
||||
: att.parsingStatus === 'approved'
|
||||
? 'green.500'
|
||||
: 'gray.500',
|
||||
color: att.parsingStatus === 'needs_review' ? 'yellow.900' : 'white',
|
||||
})}
|
||||
>
|
||||
{att.parsingStatus === 'processing' && '⏳'}
|
||||
{att.parsingStatus === 'needs_review' && '⚠️'}
|
||||
{att.parsingStatus === 'approved' && '✓'}
|
||||
{att.parsingStatus === 'processing'
|
||||
? 'Analyzing...'
|
||||
: att.parsingStatus === 'needs_review'
|
||||
? `${att.rawParsingResult?.problems?.length ?? '?'} problems`
|
||||
: att.parsingStatus === 'approved'
|
||||
? `${att.rawParsingResult?.problems?.length ?? '?'} problems`
|
||||
: att.parsingStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session created indicator */}
|
||||
{att.sessionCreated && (
|
||||
<div
|
||||
data-element="session-created"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '0.5rem',
|
||||
right: '0.5rem',
|
||||
height: '24px',
|
||||
paddingX: '0.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
borderRadius: 'full',
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: '600',
|
||||
backgroundColor: 'green.600',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
✓ Session Created
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
|
@ -417,24 +601,219 @@ export function OfflineWorkSection({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Coming Soon footer - subtle, integrated */}
|
||||
<div
|
||||
data-element="coming-soon-hint"
|
||||
className={css({
|
||||
marginTop: '1rem',
|
||||
paddingTop: '0.75rem',
|
||||
borderTop: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
fontSize: '0.8125rem',
|
||||
})}
|
||||
>
|
||||
<span>🔮</span>
|
||||
<span>Coming soon: Auto-analyze worksheets to track progress</span>
|
||||
</div>
|
||||
{/* Remediation banner - shown when teacher can't upload because student isn't present */}
|
||||
{showRemediation && (
|
||||
<div
|
||||
data-element="upload-remediation"
|
||||
className={css({
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'orange.900/30' : 'orange.50',
|
||||
border: '2px solid',
|
||||
borderColor: isDark ? 'orange.700' : 'orange.300',
|
||||
borderRadius: '12px',
|
||||
})}
|
||||
>
|
||||
{!promptSent ? (
|
||||
<>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'orange.300' : 'orange.700',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{studentName || 'This student'} is not in your classroom
|
||||
</h4>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
marginBottom: '1rem',
|
||||
lineHeight: '1.5',
|
||||
})}
|
||||
>
|
||||
To upload photos for {studentName || 'this student'}, they need to enter your
|
||||
classroom first. Send a notification to their parent to have them join.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendEntryPrompt}
|
||||
disabled={sendEntryPrompt.isPending}
|
||||
data-action="send-entry-prompt"
|
||||
className={css({
|
||||
padding: '0.625rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
backgroundColor: isDark ? 'orange.600' : 'orange.500',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: sendEntryPrompt.isPending ? 'wait' : 'pointer',
|
||||
opacity: sendEntryPrompt.isPending ? 0.7 : 1,
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'orange.500' : 'orange.600',
|
||||
},
|
||||
_disabled: {
|
||||
cursor: 'wait',
|
||||
opacity: 0.7,
|
||||
},
|
||||
})}
|
||||
>
|
||||
{sendEntryPrompt.isPending ? 'Sending...' : 'Send Entry Prompt'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
})}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: '500',
|
||||
color: isDark ? 'green.300' : 'green.700',
|
||||
})}
|
||||
>
|
||||
Entry prompt sent to {studentName || 'the student'}'s parent
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parsing hint footer - only show if no parsed results yet */}
|
||||
{onParse && parsedAttachments.length === 0 && (
|
||||
<div
|
||||
data-element="parsing-hint"
|
||||
className={css({
|
||||
marginTop: '1rem',
|
||||
paddingTop: '0.75rem',
|
||||
borderTop: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
fontSize: '0.8125rem',
|
||||
})}
|
||||
>
|
||||
<span>✨</span>
|
||||
<span>
|
||||
Click “Parse” on any photo to auto-extract problems from worksheets
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parsed Results Section - show when any photo has parsing results */}
|
||||
{parsedAttachments.length > 0 && (
|
||||
<div
|
||||
data-element="parsed-results-section"
|
||||
className={css({
|
||||
marginTop: '1rem',
|
||||
paddingTop: '1rem',
|
||||
borderTop: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
{/* Section header with photo selector if multiple parsed photos */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span>📊</span>
|
||||
Extracted Problems
|
||||
</h4>
|
||||
|
||||
{/* Photo selector tabs when multiple parsed photos */}
|
||||
{parsedAttachments.length > 1 && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{parsedAttachments.map((att, index) => {
|
||||
const photoIndex = attachments.findIndex((a) => a.id === att.id)
|
||||
return (
|
||||
<button
|
||||
key={att.id}
|
||||
type="button"
|
||||
onClick={() => setExpandedResultId(att.id)}
|
||||
className={css({
|
||||
px: 2,
|
||||
py: 1,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '500',
|
||||
borderRadius: 'md',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
backgroundColor:
|
||||
expandedResultId === att.id
|
||||
? isDark
|
||||
? 'blue.600'
|
||||
: 'blue.500'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.100',
|
||||
color:
|
||||
expandedResultId === att.id ? 'white' : isDark ? 'gray.300' : 'gray.700',
|
||||
_hover: {
|
||||
backgroundColor:
|
||||
expandedResultId === att.id
|
||||
? isDark
|
||||
? 'blue.500'
|
||||
: 'blue.600'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Photo {photoIndex + 1}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show the selected parsed result */}
|
||||
{parsedAttachments.map((att) => {
|
||||
if (att.id !== expandedResultId) return null
|
||||
if (!att.rawParsingResult) return null
|
||||
|
||||
return <ParsedProblemsList key={att.id} result={att.rawParsingResult} isDark={isDark} />
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ export interface PhotoViewerEditorPhoto {
|
|||
originalUrl: string | null
|
||||
corners: Array<{ x: number; y: number }> | null
|
||||
rotation: 0 | 90 | 180 | 270
|
||||
/** Parsing status for this photo */
|
||||
parsingStatus?: 'pending' | 'processing' | 'needs_review' | 'approved' | 'failed' | null
|
||||
/** Number of problems found (if parsed) */
|
||||
problemCount?: number
|
||||
/** Whether a session was created from this photo */
|
||||
sessionCreated?: boolean
|
||||
}
|
||||
|
||||
interface PhotoViewerEditorProps {
|
||||
|
|
@ -33,6 +39,10 @@ interface PhotoViewerEditorProps {
|
|||
corners: Array<{ x: number; y: number }>,
|
||||
rotation: 0 | 90 | 180 | 270
|
||||
) => Promise<void>
|
||||
/** Callback to parse worksheet (optional - if not provided, no parse button shown) */
|
||||
onParse?: (photoId: string) => void
|
||||
/** ID of the photo currently being parsed (null if none) */
|
||||
parsingPhotoId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,6 +60,8 @@ export function PhotoViewerEditor({
|
|||
isOpen,
|
||||
onClose,
|
||||
onEditConfirm,
|
||||
onParse,
|
||||
parsingPhotoId = null,
|
||||
}: PhotoViewerEditorProps): ReactNode {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
||||
const [mode, setMode] = useState<'view' | 'edit'>(initialMode)
|
||||
|
|
@ -359,51 +371,172 @@ export function PhotoViewerEditor({
|
|||
×
|
||||
</button>
|
||||
|
||||
{/* Edit button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="enter-edit-mode"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEnterEditMode()
|
||||
}}
|
||||
disabled={isLoadingOriginal || !isDetectionReady}
|
||||
{/* Toolbar - Edit and Parse buttons */}
|
||||
<div
|
||||
data-element="viewer-toolbar"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
left: '1rem',
|
||||
px: 4,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
border: 'none',
|
||||
borderRadius: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
aria-label="Edit photo"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isLoadingOriginal ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
<>
|
||||
<span>✏️</span>
|
||||
<span>Edit</span>
|
||||
</>
|
||||
{/* Edit button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="enter-edit-mode"
|
||||
onClick={handleEnterEditMode}
|
||||
disabled={isLoadingOriginal || !isDetectionReady}
|
||||
className={css({
|
||||
px: 4,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
border: 'none',
|
||||
borderRadius: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
aria-label="Edit photo"
|
||||
>
|
||||
{isLoadingOriginal ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
<>
|
||||
<span>✏️</span>
|
||||
<span>Edit</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Parse button - show if not parsed OR if failed (to allow retry) */}
|
||||
{onParse &&
|
||||
(!currentPhoto.parsingStatus || currentPhoto.parsingStatus === 'failed') &&
|
||||
!currentPhoto.sessionCreated && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="parse-worksheet"
|
||||
onClick={() => onParse(currentPhoto.id)}
|
||||
disabled={parsingPhotoId === currentPhoto.id}
|
||||
className={css({
|
||||
px: 4,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: 'white',
|
||||
backgroundColor:
|
||||
currentPhoto.parsingStatus === 'failed' ? 'orange.500' : 'blue.500',
|
||||
border: 'none',
|
||||
borderRadius: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
_hover: {
|
||||
backgroundColor:
|
||||
currentPhoto.parsingStatus === 'failed' ? 'orange.600' : 'blue.600',
|
||||
},
|
||||
_disabled: {
|
||||
backgroundColor: 'gray.500',
|
||||
cursor: 'wait',
|
||||
},
|
||||
})}
|
||||
aria-label={
|
||||
currentPhoto.parsingStatus === 'failed' ? 'Retry parsing' : 'Parse worksheet'
|
||||
}
|
||||
>
|
||||
{parsingPhotoId === currentPhoto.id ? (
|
||||
<>
|
||||
<span>⏳</span>
|
||||
<span>Analyzing...</span>
|
||||
</>
|
||||
) : currentPhoto.parsingStatus === 'failed' ? (
|
||||
<>
|
||||
<span>🔄</span>
|
||||
<span>Retry Parse</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>🔍</span>
|
||||
<span>Parse Worksheet</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Parsing status badge - don't show for 'failed' since retry button is shown instead */}
|
||||
{currentPhoto.parsingStatus && currentPhoto.parsingStatus !== 'failed' && (
|
||||
<div
|
||||
data-element="parsing-status-badge"
|
||||
className={css({
|
||||
px: 4,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
borderRadius: 'lg',
|
||||
backgroundColor:
|
||||
currentPhoto.parsingStatus === 'processing'
|
||||
? 'blue.500'
|
||||
: currentPhoto.parsingStatus === 'needs_review'
|
||||
? 'yellow.500'
|
||||
: currentPhoto.parsingStatus === 'approved'
|
||||
? 'green.500'
|
||||
: 'gray.500',
|
||||
color: currentPhoto.parsingStatus === 'needs_review' ? 'yellow.900' : 'white',
|
||||
})}
|
||||
>
|
||||
{currentPhoto.parsingStatus === 'processing' && '⏳'}
|
||||
{currentPhoto.parsingStatus === 'needs_review' && '⚠️'}
|
||||
{currentPhoto.parsingStatus === 'approved' && '✓'}
|
||||
{currentPhoto.parsingStatus === 'processing'
|
||||
? 'Analyzing...'
|
||||
: currentPhoto.parsingStatus === 'needs_review'
|
||||
? `${currentPhoto.problemCount ?? '?'} problems (needs review)`
|
||||
: currentPhoto.parsingStatus === 'approved'
|
||||
? `${currentPhoto.problemCount ?? '?'} problems`
|
||||
: currentPhoto.parsingStatus}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Session created badge */}
|
||||
{currentPhoto.sessionCreated && (
|
||||
<div
|
||||
data-element="session-created-badge"
|
||||
className={css({
|
||||
px: 4,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
borderRadius: 'lg',
|
||||
backgroundColor: 'green.600',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
✓ Session Created
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Previous button */}
|
||||
{hasMultiple && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,401 @@
|
|||
'use client'
|
||||
|
||||
/**
|
||||
* ParsedProblemsList - Displays extracted problems from worksheet parsing
|
||||
*
|
||||
* Shows a compact list of parsed problems with:
|
||||
* - Problem number and terms (e.g., "45 + 27")
|
||||
* - Student answer with correct/incorrect indicator
|
||||
* - Low confidence highlighting
|
||||
* - Needs review badge
|
||||
*/
|
||||
|
||||
import { css } from '../../../styled-system/css'
|
||||
import type { ParsedProblem, WorksheetParsingResult } from '@/lib/worksheet-parsing'
|
||||
|
||||
export interface ParsedProblemsListProps {
|
||||
/** The parsed result from worksheet parsing */
|
||||
result: WorksheetParsingResult
|
||||
/** Whether to use dark mode styling */
|
||||
isDark: boolean
|
||||
/** Optional callback when a problem is clicked (for highlighting on image) */
|
||||
onProblemClick?: (problem: ParsedProblem) => void
|
||||
/** Currently selected problem index (for highlighting) */
|
||||
selectedProblemIndex?: number | null
|
||||
/** Threshold below which confidence is considered "low" */
|
||||
lowConfidenceThreshold?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Format terms into a readable string like "45 + 27 - 12"
|
||||
*/
|
||||
function formatTerms(terms: number[]): string {
|
||||
if (terms.length === 0) return ''
|
||||
if (terms.length === 1) return terms[0].toString()
|
||||
|
||||
return terms
|
||||
.map((term, i) => {
|
||||
if (i === 0) return term.toString()
|
||||
if (term >= 0) return `+ ${term}`
|
||||
return `- ${Math.abs(term)}`
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum confidence for a problem (either terms or student answer)
|
||||
*/
|
||||
function getMinConfidence(problem: ParsedProblem): number {
|
||||
// If student answer is null, only consider terms confidence
|
||||
if (problem.studentAnswer === null) {
|
||||
return problem.termsConfidence
|
||||
}
|
||||
return Math.min(problem.termsConfidence, problem.studentAnswerConfidence)
|
||||
}
|
||||
|
||||
export function ParsedProblemsList({
|
||||
result,
|
||||
isDark,
|
||||
onProblemClick,
|
||||
selectedProblemIndex,
|
||||
lowConfidenceThreshold = 0.7,
|
||||
}: ParsedProblemsListProps) {
|
||||
const { problems, needsReview, overallConfidence } = result
|
||||
|
||||
// Calculate summary stats
|
||||
const totalProblems = problems.length
|
||||
const answeredProblems = problems.filter((p) => p.studentAnswer !== null).length
|
||||
const correctProblems = problems.filter(
|
||||
(p) => p.studentAnswer !== null && p.studentAnswer === p.correctAnswer
|
||||
).length
|
||||
const lowConfidenceCount = problems.filter(
|
||||
(p) => getMinConfidence(p) < lowConfidenceThreshold
|
||||
).length
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="parsed-problems-list"
|
||||
className={css({
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{/* Header with summary */}
|
||||
<div
|
||||
data-element="header"
|
||||
className={css({
|
||||
padding: '0.75rem 1rem',
|
||||
backgroundColor: isDark ? 'gray.750' : 'gray.50',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{totalProblems} Problems
|
||||
</span>
|
||||
{answeredProblems > 0 && (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
{correctProblems}/{answeredProblems} correct
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{/* Needs review badge */}
|
||||
{needsReview && (
|
||||
<span
|
||||
data-element="needs-review-badge"
|
||||
className={css({
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: '600',
|
||||
borderRadius: 'full',
|
||||
backgroundColor: 'yellow.100',
|
||||
color: 'yellow.800',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<span>⚠️</span> Needs Review
|
||||
</span>
|
||||
)}
|
||||
{/* Low confidence count */}
|
||||
{lowConfidenceCount > 0 && !needsReview && (
|
||||
<span
|
||||
className={css({
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: '500',
|
||||
borderRadius: 'full',
|
||||
backgroundColor: isDark ? 'yellow.900/30' : 'yellow.50',
|
||||
color: isDark ? 'yellow.400' : 'yellow.700',
|
||||
})}
|
||||
>
|
||||
{lowConfidenceCount} low confidence
|
||||
</span>
|
||||
)}
|
||||
{/* Confidence indicator */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.6875rem',
|
||||
color:
|
||||
overallConfidence >= 0.9
|
||||
? isDark
|
||||
? 'green.400'
|
||||
: 'green.600'
|
||||
: overallConfidence >= 0.7
|
||||
? isDark
|
||||
? 'yellow.400'
|
||||
: 'yellow.600'
|
||||
: isDark
|
||||
? 'red.400'
|
||||
: 'red.600',
|
||||
})}
|
||||
>
|
||||
{Math.round(overallConfidence * 100)}% confidence
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Problems list */}
|
||||
<div
|
||||
data-element="problems-list"
|
||||
className={css({
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
})}
|
||||
>
|
||||
{problems.map((problem, index) => {
|
||||
const isCorrect =
|
||||
problem.studentAnswer !== null && problem.studentAnswer === problem.correctAnswer
|
||||
const isIncorrect =
|
||||
problem.studentAnswer !== null && problem.studentAnswer !== problem.correctAnswer
|
||||
const isLowConfidence = getMinConfidence(problem) < lowConfidenceThreshold
|
||||
const isSelected = selectedProblemIndex === index
|
||||
|
||||
return (
|
||||
<button
|
||||
key={problem.problemNumber}
|
||||
type="button"
|
||||
data-element="problem-row"
|
||||
data-problem-number={problem.problemNumber}
|
||||
data-is-correct={isCorrect}
|
||||
data-is-low-confidence={isLowConfidence}
|
||||
onClick={() => onProblemClick?.(problem)}
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0.5rem 1rem',
|
||||
gap: '0.75rem',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.100',
|
||||
backgroundColor: isSelected
|
||||
? isDark
|
||||
? 'blue.900/30'
|
||||
: 'blue.50'
|
||||
: isLowConfidence
|
||||
? isDark
|
||||
? 'yellow.900/20'
|
||||
: 'yellow.50'
|
||||
: 'transparent',
|
||||
cursor: onProblemClick ? 'pointer' : 'default',
|
||||
transition: 'background-color 0.15s',
|
||||
border: 'none',
|
||||
textAlign: 'left',
|
||||
_hover: {
|
||||
backgroundColor: isSelected
|
||||
? isDark
|
||||
? 'blue.900/40'
|
||||
: 'blue.100'
|
||||
: isDark
|
||||
? 'gray.750'
|
||||
: 'gray.50',
|
||||
},
|
||||
_last: {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Problem number */}
|
||||
<span
|
||||
className={css({
|
||||
minWidth: '24px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
})}
|
||||
>
|
||||
#{problem.problemNumber}
|
||||
</span>
|
||||
|
||||
{/* Terms */}
|
||||
<span
|
||||
className={css({
|
||||
flex: 1,
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: 'monospace',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
{formatTerms(problem.terms)}
|
||||
</span>
|
||||
|
||||
{/* Equals sign and answer */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
=
|
||||
</span>
|
||||
|
||||
{/* Student answer */}
|
||||
<span
|
||||
className={css({
|
||||
minWidth: '48px',
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: '500',
|
||||
textAlign: 'right',
|
||||
color:
|
||||
problem.studentAnswer === null
|
||||
? isDark
|
||||
? 'gray.500'
|
||||
: 'gray.400'
|
||||
: isCorrect
|
||||
? isDark
|
||||
? 'green.400'
|
||||
: 'green.600'
|
||||
: isDark
|
||||
? 'red.400'
|
||||
: 'red.600',
|
||||
})}
|
||||
>
|
||||
{problem.studentAnswer ?? '—'}
|
||||
</span>
|
||||
|
||||
{/* Correct/incorrect indicator */}
|
||||
<span
|
||||
className={css({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
{isCorrect && <span className={css({ color: 'green.500' })}>✓</span>}
|
||||
{isIncorrect && <span className={css({ color: 'red.500' })}>✗</span>}
|
||||
{problem.studentAnswer === null && (
|
||||
<span className={css({ color: isDark ? 'gray.600' : 'gray.300' })}>—</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Low confidence indicator */}
|
||||
{isLowConfidence && (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.6875rem',
|
||||
color: isDark ? 'yellow.400' : 'yellow.600',
|
||||
})}
|
||||
title={`${Math.round(getMinConfidence(problem) * 100)}% confidence`}
|
||||
>
|
||||
⚠️
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Warnings section if any */}
|
||||
{result.warnings.length > 0 && (
|
||||
<div
|
||||
data-element="warnings"
|
||||
className={css({
|
||||
padding: '0.75rem 1rem',
|
||||
backgroundColor: isDark ? 'yellow.900/20' : 'yellow.50',
|
||||
borderTop: '1px solid',
|
||||
borderColor: isDark ? 'yellow.800/30' : 'yellow.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'yellow.400' : 'yellow.700',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
Warnings:
|
||||
</div>
|
||||
<ul
|
||||
className={css({
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyle: 'none',
|
||||
})}
|
||||
>
|
||||
{result.warnings.map((warning, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={css({
|
||||
fontSize: '0.6875rem',
|
||||
color: isDark ? 'yellow.300' : 'yellow.800',
|
||||
paddingLeft: '0.75rem',
|
||||
position: 'relative',
|
||||
_before: {
|
||||
content: '"•"',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
},
|
||||
})}
|
||||
>
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ParsedProblemsList
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Worksheet Parsing UI Components
|
||||
*
|
||||
* Components for displaying and interacting with LLM-parsed worksheet data.
|
||||
*/
|
||||
|
||||
export { ParsedProblemsList, type ParsedProblemsListProps } from './ParsedProblemsList'
|
||||
|
|
@ -60,7 +60,9 @@ export const practiceAttachments = sqliteTable('practice_attachments', {
|
|||
parsingError: text('parsing_error'), // Error message if parsing failed
|
||||
|
||||
// LLM parsing results (raw from LLM, before user corrections)
|
||||
rawParsingResult: text('raw_parsing_result', { mode: 'json' }).$type<WorksheetParsingResult | null>(),
|
||||
rawParsingResult: text('raw_parsing_result', {
|
||||
mode: 'json',
|
||||
}).$type<WorksheetParsingResult | null>(),
|
||||
|
||||
// Approved results (after user corrections)
|
||||
approvedResult: text('approved_result', { mode: 'json' }).$type<WorksheetParsingResult | null>(),
|
||||
|
|
@ -71,7 +73,9 @@ export const practiceAttachments = sqliteTable('practice_attachments', {
|
|||
|
||||
// Session linkage (for parsed worksheets that created sessions)
|
||||
sessionCreated: integer('session_created', { mode: 'boolean' }), // True if session was created from this parsing
|
||||
createdSessionId: text('created_session_id').references(() => sessionPlans.id, { onDelete: 'set null' }),
|
||||
createdSessionId: text('created_session_id').references(() => sessionPlans.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
|
||||
// Audit
|
||||
uploadedBy: text('uploaded_by')
|
||||
|
|
|
|||
|
|
@ -57,10 +57,7 @@ interface LLMVisionRequest extends LLMCallRequest {
|
|||
*/
|
||||
export function useLLMCall<T extends z.ZodType>(
|
||||
schema: T,
|
||||
options?: Omit<
|
||||
UseMutationOptions<LLMResponse<z.infer<T>>, Error, LLMCallRequest>,
|
||||
'mutationFn'
|
||||
>
|
||||
options?: Omit<UseMutationOptions<LLMResponse<z.infer<T>>, Error, LLMCallRequest>, 'mutationFn'>
|
||||
) {
|
||||
const [progress, setProgress] = useState<LLMProgress | null>(null)
|
||||
|
||||
|
|
@ -103,10 +100,7 @@ export function useLLMCall<T extends z.ZodType>(
|
|||
*/
|
||||
export function useLLMVision<T extends z.ZodType>(
|
||||
schema: T,
|
||||
options?: Omit<
|
||||
UseMutationOptions<LLMResponse<z.infer<T>>, Error, LLMVisionRequest>,
|
||||
'mutationFn'
|
||||
>
|
||||
options?: Omit<UseMutationOptions<LLMResponse<z.infer<T>>, Error, LLMVisionRequest>, 'mutationFn'>
|
||||
) {
|
||||
const [progress, setProgress] = useState<LLMProgress | null>(null)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Hook to check viewer's access level to a player
|
||||
*
|
||||
* Used for pre-flight authorization checks before showing UI that requires
|
||||
* specific access levels.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { AccessLevel } from '@/lib/classroom'
|
||||
import { api } from '@/lib/queryClient'
|
||||
|
||||
export interface PlayerAccessData {
|
||||
accessLevel: AccessLevel
|
||||
isParent: boolean
|
||||
isTeacher: boolean
|
||||
isPresent: boolean
|
||||
/** Classroom ID if the viewer is a teacher */
|
||||
classroomId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key factory for player access
|
||||
*/
|
||||
export const playerAccessKeys = {
|
||||
all: ['player-access'] as const,
|
||||
detail: (playerId: string) => [...playerAccessKeys.all, playerId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the current viewer's access level to a player
|
||||
*
|
||||
* Returns access information including:
|
||||
* - accessLevel: 'none' | 'teacher-enrolled' | 'teacher-present' | 'parent'
|
||||
* - isParent: true if viewer is a parent of the player
|
||||
* - isTeacher: true if player is enrolled in viewer's classroom
|
||||
* - isPresent: true if player is currently present in viewer's classroom
|
||||
*/
|
||||
export function usePlayerAccess(playerId: string) {
|
||||
return useQuery({
|
||||
queryKey: playerAccessKeys.detail(playerId),
|
||||
queryFn: async (): Promise<PlayerAccessData> => {
|
||||
const response = await api(`players/${playerId}/access`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check player access')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
// Refetch on window focus to catch presence changes
|
||||
refetchOnWindowFocus: true,
|
||||
// Keep data fresh - presence can change anytime
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if the viewer can upload photos for a player
|
||||
*
|
||||
* Upload requires either:
|
||||
* - Being a parent (full access)
|
||||
* - Being a teacher with the student present in classroom
|
||||
*
|
||||
* Note: This mirrors the server-side logic in the attachments API
|
||||
*/
|
||||
export function canUploadPhotos(access: PlayerAccessData | undefined): boolean {
|
||||
if (!access) return false
|
||||
return access.isParent || access.isPresent
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get remediation info for upload-restricted access
|
||||
*/
|
||||
export function getUploadRemediation(access: PlayerAccessData | undefined): {
|
||||
type: 'send-entry-prompt' | 'enroll-student' | 'link-via-family-code' | 'no-access' | null
|
||||
message: string | null
|
||||
} {
|
||||
if (!access) {
|
||||
return { type: null, message: null }
|
||||
}
|
||||
|
||||
// Can upload - no remediation needed
|
||||
if (canUploadPhotos(access)) {
|
||||
return { type: null, message: null }
|
||||
}
|
||||
|
||||
// Teacher with enrolled student, but student not present
|
||||
if (access.accessLevel === 'teacher-enrolled' && !access.isPresent) {
|
||||
return {
|
||||
type: 'send-entry-prompt',
|
||||
message:
|
||||
'This student is enrolled in your classroom but not currently present. To upload photos, they need to enter your classroom first.',
|
||||
}
|
||||
}
|
||||
|
||||
// User has some access but not enough
|
||||
if (access.accessLevel !== 'none') {
|
||||
return {
|
||||
type: 'no-access',
|
||||
message: "You don't have permission to upload photos for this student.",
|
||||
}
|
||||
}
|
||||
|
||||
// No access at all
|
||||
return {
|
||||
type: 'link-via-family-code',
|
||||
message: 'Your account is not linked to this student.',
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
'use client'
|
||||
|
||||
/**
|
||||
* React Query hooks for worksheet parsing workflow
|
||||
*
|
||||
* Provides mutations for:
|
||||
* - Starting worksheet parsing (POST /parse)
|
||||
* - Submitting corrections (PATCH /review)
|
||||
* - Approving and creating session (POST /approve)
|
||||
*
|
||||
* Includes optimistic updates for immediate UI feedback.
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/queryClient'
|
||||
import { attachmentKeys, sessionPlanKeys, sessionHistoryKeys } from '@/lib/queryKeys'
|
||||
import type { WorksheetParsingResult, computeParsingStats } from '@/lib/worksheet-parsing'
|
||||
import type { ParsingStatus } from '@/db/schema/practice-attachments'
|
||||
|
||||
/** Stats returned from parsing */
|
||||
type ParsingStats = ReturnType<typeof computeParsingStats>
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/** Extended attachment data with parsing fields */
|
||||
export interface AttachmentWithParsing {
|
||||
id: string
|
||||
filename: string
|
||||
originalFilename: string | null
|
||||
mimeType: string
|
||||
fileSize: number
|
||||
uploadedAt: string
|
||||
url: string
|
||||
originalUrl: string | null
|
||||
corners: Array<{ x: number; y: number }> | null
|
||||
rotation: 0 | 90 | 180 | 270
|
||||
// Parsing fields
|
||||
parsingStatus: ParsingStatus | null
|
||||
parsedAt: string | null
|
||||
parsingError: string | null
|
||||
rawParsingResult: WorksheetParsingResult | null
|
||||
approvedResult: WorksheetParsingResult | null
|
||||
confidenceScore: number | null
|
||||
needsReview: boolean
|
||||
sessionCreated: boolean
|
||||
createdSessionId: string | null
|
||||
}
|
||||
|
||||
/** Response from parse API */
|
||||
interface ParseResponse {
|
||||
success: boolean
|
||||
status: ParsingStatus
|
||||
result?: WorksheetParsingResult
|
||||
stats?: ParsingStats
|
||||
error?: string
|
||||
attempts?: number
|
||||
}
|
||||
|
||||
/** Response from approve API */
|
||||
interface ApproveResponse {
|
||||
success: boolean
|
||||
sessionId: string
|
||||
problemCount: number
|
||||
correctCount: number
|
||||
accuracy: number | null
|
||||
skillsExercised: string[]
|
||||
stats: ParsingStats
|
||||
}
|
||||
|
||||
/** Cached session attachments shape */
|
||||
interface AttachmentsCache {
|
||||
attachments: AttachmentWithParsing[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to start parsing a worksheet attachment
|
||||
*/
|
||||
export function useStartParsing(playerId: string, sessionId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
const queryKey = attachmentKeys.session(playerId, sessionId)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (attachmentId: string) => {
|
||||
const res = await api(`curriculum/${playerId}/attachments/${attachmentId}/parse`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
throw new Error(error.error || 'Failed to start parsing')
|
||||
}
|
||||
return res.json() as Promise<ParseResponse>
|
||||
},
|
||||
|
||||
onMutate: async (attachmentId) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
|
||||
// Snapshot current state
|
||||
const previous = queryClient.getQueryData<AttachmentsCache>(queryKey)
|
||||
|
||||
// Optimistic update: mark as processing
|
||||
if (previous) {
|
||||
queryClient.setQueryData<AttachmentsCache>(queryKey, {
|
||||
...previous,
|
||||
attachments: previous.attachments.map((a) =>
|
||||
a.id === attachmentId
|
||||
? { ...a, parsingStatus: 'processing' as ParsingStatus, parsingError: null }
|
||||
: a
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return { previous }
|
||||
},
|
||||
|
||||
onError: (_err, attachmentId, context) => {
|
||||
// Revert on error
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(queryKey, context.previous)
|
||||
}
|
||||
},
|
||||
|
||||
onSuccess: (data, attachmentId) => {
|
||||
// Update cache with actual result
|
||||
const current = queryClient.getQueryData<AttachmentsCache>(queryKey)
|
||||
if (current && data.success) {
|
||||
queryClient.setQueryData<AttachmentsCache>(queryKey, {
|
||||
...current,
|
||||
attachments: current.attachments.map((a) =>
|
||||
a.id === attachmentId
|
||||
? {
|
||||
...a,
|
||||
parsingStatus: data.status,
|
||||
rawParsingResult: data.result ?? null,
|
||||
confidenceScore: data.result?.overallConfidence ?? null,
|
||||
needsReview: data.result?.needsReview ?? false,
|
||||
parsedAt: new Date().toISOString(),
|
||||
}
|
||||
: a
|
||||
),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
// Always refetch to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to submit corrections to parsed problems
|
||||
*/
|
||||
export function useSubmitCorrections(playerId: string, sessionId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
const queryKey = attachmentKeys.session(playerId, sessionId)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
attachmentId,
|
||||
corrections,
|
||||
markAsReviewed = false,
|
||||
}: {
|
||||
attachmentId: string
|
||||
corrections: Array<{
|
||||
problemNumber: number
|
||||
correctedTerms?: number[] | null
|
||||
correctedStudentAnswer?: number | null
|
||||
shouldExclude?: boolean
|
||||
}>
|
||||
markAsReviewed?: boolean
|
||||
}) => {
|
||||
const res = await api(`curriculum/${playerId}/attachments/${attachmentId}/review`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ corrections, markAsReviewed }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
throw new Error(error.error || 'Failed to submit corrections')
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
// Refetch to get updated data
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to approve parsing and create a practice session
|
||||
*/
|
||||
export function useApproveAndCreateSession(playerId: string, sessionId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
const queryKey = attachmentKeys.session(playerId, sessionId)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (attachmentId: string) => {
|
||||
const res = await api(`curriculum/${playerId}/attachments/${attachmentId}/approve`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
throw new Error(error.error || 'Failed to approve and create session')
|
||||
}
|
||||
return res.json() as Promise<ApproveResponse>
|
||||
},
|
||||
|
||||
onMutate: async (attachmentId) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
|
||||
// Snapshot current state
|
||||
const previous = queryClient.getQueryData<AttachmentsCache>(queryKey)
|
||||
|
||||
// Optimistic update: mark as creating session
|
||||
if (previous) {
|
||||
queryClient.setQueryData<AttachmentsCache>(queryKey, {
|
||||
...previous,
|
||||
attachments: previous.attachments.map((a) =>
|
||||
a.id === attachmentId ? { ...a, sessionCreated: true } : a
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return { previous }
|
||||
},
|
||||
|
||||
onError: (_err, _attachmentId, context) => {
|
||||
// Revert on error
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(queryKey, context.previous)
|
||||
}
|
||||
},
|
||||
|
||||
onSuccess: (data, attachmentId) => {
|
||||
// Update cache with session ID
|
||||
const current = queryClient.getQueryData<AttachmentsCache>(queryKey)
|
||||
if (current && data.success) {
|
||||
queryClient.setQueryData<AttachmentsCache>(queryKey, {
|
||||
...current,
|
||||
attachments: current.attachments.map((a) =>
|
||||
a.id === attachmentId
|
||||
? {
|
||||
...a,
|
||||
sessionCreated: true,
|
||||
createdSessionId: data.sessionId,
|
||||
parsingStatus: 'approved' as ParsingStatus,
|
||||
}
|
||||
: a
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// Invalidate session-related queries so new session appears
|
||||
queryClient.invalidateQueries({ queryKey: sessionPlanKeys.list(playerId) })
|
||||
queryClient.invalidateQueries({ queryKey: sessionHistoryKeys.list(playerId) })
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
// Always refetch attachments to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parsing status badge color
|
||||
*/
|
||||
export function getParsingStatusColor(status: ParsingStatus | null): string {
|
||||
switch (status) {
|
||||
case 'processing':
|
||||
return 'blue.500'
|
||||
case 'needs_review':
|
||||
return 'yellow.500'
|
||||
case 'approved':
|
||||
return 'green.500'
|
||||
case 'failed':
|
||||
return 'red.500'
|
||||
default:
|
||||
return 'gray.500'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parsing status display text
|
||||
*/
|
||||
export function getParsingStatusText(status: ParsingStatus | null, problemCount?: number): string {
|
||||
switch (status) {
|
||||
case 'processing':
|
||||
return 'Analyzing...'
|
||||
case 'needs_review':
|
||||
return problemCount ? `${problemCount} problems (needs review)` : 'Needs review'
|
||||
case 'approved':
|
||||
return problemCount ? `${problemCount} problems` : 'Ready'
|
||||
case 'failed':
|
||||
return 'Failed'
|
||||
default:
|
||||
return 'Not parsed'
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ import {
|
|||
classrooms,
|
||||
parentChild,
|
||||
type Player,
|
||||
players,
|
||||
} from '@/db/schema'
|
||||
|
||||
/**
|
||||
|
|
@ -236,3 +235,107 @@ export async function isTeacherOf(userId: string, playerId: string): Promise<boo
|
|||
|
||||
return !!enrollment
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation types for authorization errors
|
||||
*/
|
||||
export type RemediationType =
|
||||
| 'send-entry-prompt' // Teacher needs student to enter classroom
|
||||
| 'enroll-student' // Teacher needs to enroll student first
|
||||
| 'link-via-family-code' // User can link via family code
|
||||
| 'create-classroom' // User needs to create a classroom to be a teacher
|
||||
| 'no-access' // No remediation available
|
||||
|
||||
/**
|
||||
* Structured authorization error for API responses
|
||||
*/
|
||||
export interface AuthorizationError {
|
||||
error: string
|
||||
message: string
|
||||
accessLevel: AccessLevel
|
||||
remediation: {
|
||||
type: RemediationType
|
||||
description: string
|
||||
/** For send-entry-prompt: the classroom to send the prompt from */
|
||||
classroomId?: string
|
||||
/** For send-entry-prompt/enroll-student: the player to act on */
|
||||
playerId?: string
|
||||
/** Label for the action button in the UI */
|
||||
actionLabel?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a personalized authorization error based on the user's relationship
|
||||
* with the student and the action they're trying to perform.
|
||||
*/
|
||||
export function generateAuthorizationError(
|
||||
access: PlayerAccess,
|
||||
action: PlayerAction,
|
||||
context?: { actionDescription?: string }
|
||||
): AuthorizationError {
|
||||
const actionDesc = context?.actionDescription ?? action
|
||||
|
||||
// Case 1: Teacher with enrolled student, but student not present
|
||||
// This is the most common case - teacher needs student to enter classroom
|
||||
if (access.accessLevel === 'teacher-enrolled' && !access.isPresent) {
|
||||
return {
|
||||
error: 'Student not in classroom',
|
||||
message: `This student is enrolled in your classroom but not currently present. To ${actionDesc}, they need to enter your classroom first.`,
|
||||
accessLevel: access.accessLevel,
|
||||
remediation: {
|
||||
type: 'send-entry-prompt',
|
||||
description:
|
||||
"Send a notification to the student's parent to have them enter your classroom.",
|
||||
classroomId: access.classroomId,
|
||||
playerId: access.playerId,
|
||||
actionLabel: 'Send Entry Prompt',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: User has a classroom but student is not enrolled
|
||||
if (access.accessLevel === 'none' && access.classroomId) {
|
||||
return {
|
||||
error: 'Student not enrolled',
|
||||
message: 'This student is not enrolled in your classroom.',
|
||||
accessLevel: access.accessLevel,
|
||||
remediation: {
|
||||
type: 'enroll-student',
|
||||
description:
|
||||
'You need to enroll this student in your classroom first. Ask their parent for their family code to send an enrollment request.',
|
||||
classroomId: access.classroomId,
|
||||
playerId: access.playerId,
|
||||
actionLabel: 'Enroll Student',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: User has no classroom and no parent relationship
|
||||
if (access.accessLevel === 'none') {
|
||||
return {
|
||||
error: 'No access to this student',
|
||||
message: 'Your account is not linked to this student.',
|
||||
accessLevel: access.accessLevel,
|
||||
remediation: {
|
||||
type: 'link-via-family-code',
|
||||
description:
|
||||
"To access this student, you need their Family Code. Ask their parent to share it with you from the student's profile page.",
|
||||
playerId: access.playerId,
|
||||
actionLabel: 'Enter Family Code',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for any other case
|
||||
return {
|
||||
error: 'Not authorized',
|
||||
message: `You do not have permission to ${actionDesc} for this student.`,
|
||||
accessLevel: access.accessLevel,
|
||||
remediation: {
|
||||
type: 'no-access',
|
||||
description: "Contact the student's parent or your administrator for access.",
|
||||
playerId: access.playerId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@ export {
|
|||
type PlayerAccess,
|
||||
type PlayerAction,
|
||||
type AccessiblePlayers,
|
||||
type RemediationType,
|
||||
type AuthorizationError,
|
||||
getPlayerAccess,
|
||||
canPerformAction,
|
||||
getAccessiblePlayers,
|
||||
isParentOf,
|
||||
isTeacherOf,
|
||||
generateAuthorizationError,
|
||||
} from './access-control'
|
||||
|
||||
// Family Management
|
||||
|
|
|
|||
|
|
@ -366,7 +366,7 @@ export function getPhaseSkillConstraints(phaseId: string): PhaseSkillConstraints
|
|||
if (phase.usesFiveComplement && phase.targetNumber <= 4) {
|
||||
// Target the specific five-complement skill
|
||||
const skill = FIVE_COMPLEMENT_ADD[phase.targetNumber]
|
||||
target.fiveComplements = { [skill]: true } as Partial<SkillSet['fiveComplements']>
|
||||
target.fiveComplements = { [skill]: true } as SkillSet['fiveComplements']
|
||||
required.fiveComplements = {
|
||||
[skill]: true,
|
||||
} as SkillSet['fiveComplements']
|
||||
|
|
@ -386,7 +386,7 @@ export function getPhaseSkillConstraints(phaseId: string): PhaseSkillConstraints
|
|||
|
||||
if (phase.usesFiveComplement && Math.abs(phase.targetNumber) <= 4) {
|
||||
const skill = FIVE_COMPLEMENT_SUB[Math.abs(phase.targetNumber)]
|
||||
target.fiveComplementsSub = { [skill]: true } as Partial<SkillSet['fiveComplementsSub']>
|
||||
target.fiveComplementsSub = { [skill]: true } as SkillSet['fiveComplementsSub']
|
||||
required.fiveComplementsSub = {
|
||||
[skill]: true,
|
||||
} as SkillSet['fiveComplementsSub']
|
||||
|
|
@ -405,7 +405,7 @@ export function getPhaseSkillConstraints(phaseId: string): PhaseSkillConstraints
|
|||
required.basic.heavenBead = true
|
||||
|
||||
const skill = TEN_COMPLEMENT_ADD[phase.targetNumber]
|
||||
target.tenComplements = { [skill]: true } as Partial<SkillSet['tenComplements']>
|
||||
target.tenComplements = { [skill]: true } as SkillSet['tenComplements']
|
||||
required.tenComplements = { [skill]: true } as SkillSet['tenComplements']
|
||||
|
||||
if (!phase.usesFiveComplement) {
|
||||
|
|
@ -423,7 +423,7 @@ export function getPhaseSkillConstraints(phaseId: string): PhaseSkillConstraints
|
|||
required.basic.heavenBeadSubtraction = true
|
||||
|
||||
const skill = TEN_COMPLEMENT_SUB[Math.abs(phase.targetNumber)]
|
||||
target.tenComplementsSub = { [skill]: true } as Partial<SkillSet['tenComplementsSub']>
|
||||
target.tenComplementsSub = { [skill]: true } as SkillSet['tenComplementsSub']
|
||||
required.tenComplementsSub = {
|
||||
[skill]: true,
|
||||
} as SkillSet['tenComplementsSub']
|
||||
|
|
@ -529,6 +529,10 @@ function createFullSkillSet(): SkillSet {
|
|||
'-2=+8-10': true,
|
||||
'-1=+9-10': true,
|
||||
},
|
||||
advanced: {
|
||||
cascadingCarry: true,
|
||||
cascadingBorrow: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,10 +43,10 @@ function formatDiagnosticsMessage(diagnostics: GenerationDiagnostics): string {
|
|||
lines.push(
|
||||
'This means no valid sequence of terms could be built with the given skill/budget constraints.'
|
||||
)
|
||||
if (diagnostics.enabledRequiredSkills.length === 0) {
|
||||
lines.push('FIX: No required skills are enabled - enable at least some basic skills.')
|
||||
if (diagnostics.enabledAllowedSkills.length === 0) {
|
||||
lines.push('FIX: No allowed skills are enabled - enable at least some basic skills.')
|
||||
} else {
|
||||
lines.push(`Enabled skills: ${diagnostics.enabledRequiredSkills.slice(0, 5).join(', ')}...`)
|
||||
lines.push(`Enabled skills: ${diagnostics.enabledAllowedSkills.slice(0, 5).join(', ')}...`)
|
||||
}
|
||||
} else if (diagnostics.skillMatchFailures > 0) {
|
||||
lines.push(
|
||||
|
|
|
|||
|
|
@ -58,3 +58,21 @@ export const entryPromptKeys = {
|
|||
all: ['entry-prompts'] as const,
|
||||
pending: () => [...entryPromptKeys.all, 'pending'] as const,
|
||||
}
|
||||
|
||||
// Attachment query keys (for practice photos and worksheet parsing)
|
||||
export const attachmentKeys = {
|
||||
// All attachments for a player
|
||||
all: (playerId: string) => ['attachments', playerId] as const,
|
||||
|
||||
// Attachments for a specific session
|
||||
session: (playerId: string, sessionId: string) =>
|
||||
[...attachmentKeys.all(playerId), 'session', sessionId] as const,
|
||||
|
||||
// Single attachment detail (includes parsing data)
|
||||
detail: (playerId: string, attachmentId: string) =>
|
||||
[...attachmentKeys.all(playerId), attachmentId] as const,
|
||||
|
||||
// Parsing-specific data for an attachment
|
||||
parsing: (playerId: string, attachmentId: string) =>
|
||||
[...attachmentKeys.detail(playerId, attachmentId), 'parsing'] as const,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,9 +138,7 @@ export function computeParsingStats(result: WorksheetParsingResult) {
|
|||
const answeredProblems = problems.filter((p) => p.studentAnswer !== null)
|
||||
|
||||
// Compute accuracy if answers are present
|
||||
const correctAnswers = answeredProblems.filter(
|
||||
(p) => p.studentAnswer === p.correctAnswer
|
||||
)
|
||||
const correctAnswers = answeredProblems.filter((p) => p.studentAnswer === p.correctAnswer)
|
||||
|
||||
return {
|
||||
totalProblems: problems.length,
|
||||
|
|
@ -190,9 +188,7 @@ export function applyCorrections(
|
|||
// Boost confidence since user verified
|
||||
termsConfidence: correction.correctedTerms ? 1.0 : problem.termsConfidence,
|
||||
studentAnswerConfidence:
|
||||
correction.correctedStudentAnswer !== undefined
|
||||
? 1.0
|
||||
: problem.studentAnswerConfidence,
|
||||
correction.correctedStudentAnswer !== undefined ? 1.0 : problem.studentAnswerConfidence,
|
||||
}
|
||||
})
|
||||
.filter((p): p is NonNullable<typeof p> => p !== null)
|
||||
|
|
|
|||
|
|
@ -17,22 +17,18 @@ export const BoundingBoxSchema = z
|
|||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.describe('Left edge of the box as a fraction of image width (0 = left edge, 1 = right edge)'),
|
||||
.describe(
|
||||
'Left edge of the box as a fraction of image width (0 = left edge, 1 = right edge)'
|
||||
),
|
||||
y: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.describe('Top edge of the box as a fraction of image height (0 = top edge, 1 = bottom edge)'),
|
||||
width: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.describe('Width of the box as a fraction of image width'),
|
||||
height: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.describe('Height of the box as a fraction of image height'),
|
||||
.describe(
|
||||
'Top edge of the box as a fraction of image height (0 = top edge, 1 = bottom edge)'
|
||||
),
|
||||
width: z.number().min(0).max(1).describe('Width of the box as a fraction of image width'),
|
||||
height: z.number().min(0).max(1).describe('Height of the box as a fraction of image height'),
|
||||
})
|
||||
.describe('Rectangular region on the worksheet image, in normalized 0-1 coordinates')
|
||||
|
||||
|
|
@ -95,10 +91,7 @@ export const ParsedProblemSchema = z
|
|||
'Subsequent terms are positive for addition, negative for subtraction. ' +
|
||||
'Example: "45 - 17 + 8" → [45, -17, 8]'
|
||||
),
|
||||
correctAnswer: z
|
||||
.number()
|
||||
.int()
|
||||
.describe('The mathematically correct answer to this problem'),
|
||||
correctAnswer: z.number().int().describe('The mathematically correct answer to this problem'),
|
||||
|
||||
// Student work
|
||||
studentAnswer: z
|
||||
|
|
@ -107,14 +100,14 @@ export const ParsedProblemSchema = z
|
|||
.nullable()
|
||||
.describe(
|
||||
'The answer the student wrote, if readable. Null if the answer box is empty, ' +
|
||||
'illegible, or you cannot confidently read the student\'s handwriting'
|
||||
"illegible, or you cannot confidently read the student's handwriting"
|
||||
),
|
||||
studentAnswerConfidence: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.describe(
|
||||
'Confidence in reading the student\'s answer (0 = not readable/empty, 1 = perfectly clear). ' +
|
||||
"Confidence in reading the student's answer (0 = not readable/empty, 1 = perfectly clear). " +
|
||||
'Use 0.5-0.7 for somewhat legible, 0.8-0.9 for mostly clear, 1.0 for crystal clear'
|
||||
),
|
||||
|
||||
|
|
@ -133,7 +126,7 @@ export const ParsedProblemSchema = z
|
|||
'Bounding box around the entire problem (including all terms and answer area)'
|
||||
),
|
||||
answerBoundingBox: BoundingBoxSchema.nullable().describe(
|
||||
'Bounding box around just the student\'s answer area. Null if no answer area is visible'
|
||||
"Bounding box around just the student's answer area. Null if no answer area is visible"
|
||||
),
|
||||
})
|
||||
.describe('A single arithmetic problem extracted from the worksheet')
|
||||
|
|
@ -175,9 +168,7 @@ export const PageMetadataSchema = z
|
|||
.number()
|
||||
.int()
|
||||
.nullable()
|
||||
.describe(
|
||||
'Page number if printed on the page. Null if no page number is visible'
|
||||
),
|
||||
.describe('Page number if printed on the page. Null if no page number is visible'),
|
||||
detectedFormat: WorksheetFormatSchema,
|
||||
totalRows: z
|
||||
.number()
|
||||
|
|
@ -240,11 +231,7 @@ export type WorksheetParsingResult = z.infer<typeof WorksheetParsingResultSchema
|
|||
*/
|
||||
export const ProblemCorrectionSchema = z
|
||||
.object({
|
||||
problemNumber: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.describe('The problem number being corrected'),
|
||||
problemNumber: z.number().int().min(1).describe('The problem number being corrected'),
|
||||
correctedTerms: z
|
||||
.array(ProblemTermSchema)
|
||||
.nullable()
|
||||
|
|
@ -257,10 +244,7 @@ export const ProblemCorrectionSchema = z
|
|||
shouldExclude: z
|
||||
.boolean()
|
||||
.describe('True to exclude this problem from the session (e.g., illegible)'),
|
||||
note: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Optional note explaining the correction'),
|
||||
note: z.string().nullable().describe('Optional note explaining the correction'),
|
||||
})
|
||||
.describe('User correction to a single parsed problem')
|
||||
|
||||
|
|
@ -271,9 +255,7 @@ export type ProblemCorrection = z.infer<typeof ProblemCorrectionSchema>
|
|||
*/
|
||||
export const ReparseRequestSchema = z
|
||||
.object({
|
||||
problemNumbers: z
|
||||
.array(z.number().int().min(1))
|
||||
.describe('Which problems to re-parse'),
|
||||
problemNumbers: z.array(z.number().int().min(1)).describe('Which problems to re-parse'),
|
||||
additionalContext: z
|
||||
.string()
|
||||
.describe(
|
||||
|
|
|
|||
|
|
@ -156,9 +156,10 @@ export function convertToSlotResults(
|
|||
*
|
||||
* Returns warnings for any issues found.
|
||||
*/
|
||||
export function validateParsedProblems(
|
||||
problems: ParsedProblem[]
|
||||
): { valid: boolean; warnings: string[] } {
|
||||
export function validateParsedProblems(problems: ParsedProblem[]): {
|
||||
valid: boolean
|
||||
warnings: string[]
|
||||
} {
|
||||
const warnings: string[] = []
|
||||
|
||||
for (const problem of problems) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
// Type declaration for CSS imports
|
||||
// This allows TypeScript to understand CSS imports in @soroban/abacus-react's declaration files
|
||||
declare module '*.css' {
|
||||
const content: { [className: string]: string }
|
||||
export default content
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "es6"],
|
||||
"lib": ["dom", "dom.iterable", "es2020"],
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
|
@ -8,12 +8,12 @@
|
|||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"target": "es2015",
|
||||
"target": "es2020",
|
||||
"downlevelIteration": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue