Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc0371dd9 | ||
|
|
469bcc0517 |
+7
-49
@@ -7,37 +7,13 @@ RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && n
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Vite bakes VITE_* values at build time. Pass the public VITE_* values as
|
# Vite bakes VITE_* values at build time, so compose must choose the env file
|
||||||
# build args (docker compose --env-file .env.dev) instead of COPYing an env file;
|
# during image build, not only at container runtime.
|
||||||
# service-host worktrees keep .env.dev as a symlink outside the Docker context.
|
ARG ENV_FILE=.env
|
||||||
ARG VITE_API_BASE
|
COPY ${ENV_FILE} .env
|
||||||
ARG VITE_API_URL
|
|
||||||
ARG VITE_APP_NAME
|
# Run build with production mode
|
||||||
ARG VITE_APP_HMR_URL
|
RUN npm run build -- --mode production
|
||||||
ARG VITE_DEV
|
|
||||||
ARG VITE_FRONTEND_SITE_URL
|
|
||||||
ARG VITE_SEARCH_URL
|
|
||||||
ARG VITE_SUPABASE_ANON_KEY
|
|
||||||
ARG VITE_SUPABASE_URL
|
|
||||||
ARG VITE_SUPER_ADMIN_EMAIL
|
|
||||||
ARG VITE_TLSYNC_URL
|
|
||||||
ARG VITE_WHISPERLIVE_URL
|
|
||||||
# Run build with production mode. Keep these as build-step environment values
|
|
||||||
# rather than final-image ENV entries; Vite still embeds the public client config
|
|
||||||
# into the static bundle, but nginx image metadata does not need them.
|
|
||||||
RUN VITE_API_BASE="${VITE_API_BASE}" \
|
|
||||||
VITE_API_URL="${VITE_API_URL}" \
|
|
||||||
VITE_APP_NAME="${VITE_APP_NAME}" \
|
|
||||||
VITE_APP_HMR_URL="${VITE_APP_HMR_URL}" \
|
|
||||||
VITE_DEV="${VITE_DEV}" \
|
|
||||||
VITE_FRONTEND_SITE_URL="${VITE_FRONTEND_SITE_URL}" \
|
|
||||||
VITE_SEARCH_URL="${VITE_SEARCH_URL}" \
|
|
||||||
VITE_SUPABASE_ANON_KEY="${VITE_SUPABASE_ANON_KEY}" \
|
|
||||||
VITE_SUPABASE_URL="${VITE_SUPABASE_URL}" \
|
|
||||||
VITE_SUPER_ADMIN_EMAIL="${VITE_SUPER_ADMIN_EMAIL}" \
|
|
||||||
VITE_TLSYNC_URL="${VITE_TLSYNC_URL}" \
|
|
||||||
VITE_WHISPERLIVE_URL="${VITE_WHISPERLIVE_URL}" \
|
|
||||||
npm run build -- --mode production
|
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
# Copy built files
|
# Copy built files
|
||||||
@@ -55,24 +31,6 @@ RUN echo 'server { \
|
|||||||
expires -1; \
|
expires -1; \
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
|
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
|
||||||
} \
|
} \
|
||||||
location = /health { \
|
|
||||||
proxy_pass http://192.168.0.64:18000/health; \
|
|
||||||
proxy_set_header Host $host; \
|
|
||||||
proxy_set_header X-Real-IP $remote_addr; \
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
|
||||||
} \
|
|
||||||
location /__ccapi/ { \
|
|
||||||
proxy_pass http://192.168.0.64:18000/; \
|
|
||||||
proxy_set_header Host $host; \
|
|
||||||
proxy_set_header X-Real-IP $remote_addr; \
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
|
||||||
} \
|
|
||||||
location /api/ { \
|
|
||||||
proxy_pass http://192.168.0.64:18000/api/; \
|
|
||||||
proxy_set_header Host $host; \
|
|
||||||
proxy_set_header X-Real-IP $remote_addr; \
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
|
||||||
} \
|
|
||||||
location / { \
|
location / { \
|
||||||
try_files $uri $uri/ /index.html; \
|
try_files $uri $uri/ /index.html; \
|
||||||
expires 30d; \
|
expires 30d; \
|
||||||
|
|||||||
+1
-17
@@ -16,23 +16,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
# app-dev is served by nginx on the app host; browser API calls must stay
|
ENV_FILE: .env.dev
|
||||||
# same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
|
|
||||||
# strips that prefix before forwarding, preserving mixed backend routes
|
|
||||||
# such as /api/exam, /me/bootstrap, and /database/timetable.
|
|
||||||
# .env.dev still points at the LAN API for local Vite/dev tooling.
|
|
||||||
VITE_API_BASE: /__ccapi
|
|
||||||
VITE_API_URL: /__ccapi
|
|
||||||
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
|
|
||||||
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
|
|
||||||
VITE_DEV: ${VITE_DEV:-false}
|
|
||||||
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
|
|
||||||
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
|
|
||||||
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
|
|
||||||
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL:-}
|
|
||||||
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
|
|
||||||
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
|
|
||||||
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env.dev
|
- .env.dev
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/classes" element={<ClassesPage />} />
|
<Route path="/classes" element={<ClassesPage />} />
|
||||||
<Route path="/my-classes" element={<MyClassesPage />} />
|
<Route path="/my-classes" element={<MyClassesPage />} />
|
||||||
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
||||||
<Route path="/timetable/classes/:classId" element={<ClassDetailPage />} />
|
|
||||||
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
||||||
<Route path="/lesson-plans" element={<LessonPlansPage />} />
|
<Route path="/lesson-plans" element={<LessonPlansPage />} />
|
||||||
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
|
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
|
||||||
|
|||||||
@@ -132,20 +132,10 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const clsRes = await fetch(`${API_BASE}/database/timetable/classes/${classId}`, {
|
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
if (clsRes.id) {
|
if (clsRes.id) setCls(clsRes);
|
||||||
setCls({
|
|
||||||
...clsRes,
|
|
||||||
class_code: clsRes.class_code || clsRes.code,
|
|
||||||
year_group: clsRes.year_group || clsRes.school_year,
|
|
||||||
teachers: clsRes.teachers || [],
|
|
||||||
students: clsRes.students || [],
|
|
||||||
enrollment_requests: clsRes.enrollment_requests || [],
|
|
||||||
student_count: clsRes.student_count ?? clsRes.students?.length ?? 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else setError(clsRes.detail || 'Class not found');
|
else setError(clsRes.detail || 'Class not found');
|
||||||
const role = bootstrapData?.active_institute?.membership_role || '';
|
const role = bootstrapData?.active_institute?.membership_role || '';
|
||||||
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
||||||
@@ -185,20 +175,20 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleAddStudent = async (studentId: string) => {
|
const handleAddStudent = async (studentId: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
const res = await apiPost(`/database/timetable/classes/${classId}/students`, { student_id: studentId });
|
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
|
||||||
if (res.status === 'ok') load();
|
if (res.status === 'ok') load();
|
||||||
else setActionError(res.detail || 'Failed to add student');
|
else setActionError(res.detail || 'Failed to add student');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveStudent = async (studentId: string) => {
|
const handleRemoveStudent = async (studentId: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
await apiDelete(`/database/timetable/classes/${classId}/students/${studentId}`);
|
await apiDelete(`/classes/${classId}/students/${studentId}`);
|
||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
const res = await apiPatch(`/database/timetable/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
||||||
if (res.status === 'ok') load();
|
if (res.status === 'ok') load();
|
||||||
else setActionError(res.detail || 'Action failed');
|
else setActionError(res.detail || 'Action failed');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
ExamResponseArea,
|
ExamResponseArea,
|
||||||
ExamTemplate,
|
ExamTemplate,
|
||||||
ExamTemplateDetail,
|
ExamTemplateDetail,
|
||||||
|
ExamTemplateLayout,
|
||||||
MarkingBatch,
|
MarkingBatch,
|
||||||
MarkUpsertPayload,
|
MarkUpsertPayload,
|
||||||
Neo4jSyncResult,
|
Neo4jSyncResult,
|
||||||
@@ -64,6 +65,10 @@ function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
|
|||||||
spec_ref: q.spec_ref,
|
spec_ref: q.spec_ref,
|
||||||
bounds: q.bounds ?? null,
|
bounds: q.bounds ?? null,
|
||||||
page: q.page ?? null,
|
page: q.page ?? null,
|
||||||
|
source: q.source ?? 'manual',
|
||||||
|
confirmed: q.confirmed ?? true,
|
||||||
|
confidence: q.confidence ?? null,
|
||||||
|
derivation: q.derivation ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +84,8 @@ function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, d
|
|||||||
source: r.source,
|
source: r.source,
|
||||||
confirmed: r.confirmed,
|
confirmed: r.confirmed,
|
||||||
confidence: r.confidence,
|
confidence: r.confidence,
|
||||||
|
mark_subtype: r.mark_subtype ?? null,
|
||||||
|
derivation: r.derivation ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +99,26 @@ function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate
|
|||||||
bounds: b.bounds,
|
bounds: b.bounds,
|
||||||
source: b.source,
|
source: b.source,
|
||||||
confirmed: b.confirmed,
|
confirmed: b.confirmed,
|
||||||
|
confidence: b.confidence ?? null,
|
||||||
|
derivation: b.derivation ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutPayload(layout: ExamTemplateLayout, duplicate = false) {
|
||||||
|
return {
|
||||||
|
id: duplicate ? newUuid() : layout.id,
|
||||||
|
page_index: layout.page_index,
|
||||||
|
role: layout.role ?? null,
|
||||||
|
margin_left: layout.margin_left ?? null,
|
||||||
|
margin_right: layout.margin_right ?? null,
|
||||||
|
margin_top: layout.margin_top ?? null,
|
||||||
|
margin_bottom: layout.margin_bottom ?? null,
|
||||||
|
margins_enabled: layout.margins_enabled ?? true,
|
||||||
|
source: layout.source ?? 'manual',
|
||||||
|
confirmed: layout.confirmed ?? true,
|
||||||
|
confidence: layout.confidence ?? null,
|
||||||
|
derivation: layout.derivation ?? null,
|
||||||
|
meta: layout.meta ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +140,7 @@ async function replaceTemplate(
|
|||||||
questions: detail.questions.map((q) => questionPayload(q, idMap)),
|
questions: detail.questions.map((q) => questionPayload(q, idMap)),
|
||||||
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
|
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
|
||||||
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
|
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
|
||||||
|
layout: (detail.layout ?? []).map((layout) => layoutPayload(layout, duplicateIds)),
|
||||||
},
|
},
|
||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
|
|||||||
+58
-3
@@ -75,6 +75,8 @@ export interface UpdateTemplateMetaPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||||
|
export type ExamTemplateSource = 'manual' | 'ai';
|
||||||
|
|
||||||
export interface ExamQuestion {
|
export interface ExamQuestion {
|
||||||
id: string;
|
id: string;
|
||||||
template_id: string;
|
template_id: string;
|
||||||
@@ -89,6 +91,10 @@ export interface ExamQuestion {
|
|||||||
spec_ref: string | null;
|
spec_ref: string | null;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
page?: number | null;
|
page?: number | null;
|
||||||
|
source: ExamTemplateSource;
|
||||||
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
derivation: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExamResponseAreaKind =
|
export type ExamResponseAreaKind =
|
||||||
@@ -99,6 +105,8 @@ export type ExamResponseAreaKind =
|
|||||||
| 'reference'
|
| 'reference'
|
||||||
| 'furniture';
|
| 'furniture';
|
||||||
|
|
||||||
|
export type ExamMarkSubtype = 'part_marks' | 'question_total' | 'grader_box';
|
||||||
|
|
||||||
export interface ExamResponseArea {
|
export interface ExamResponseArea {
|
||||||
id: string;
|
id: string;
|
||||||
question_id: string;
|
question_id: string;
|
||||||
@@ -108,9 +116,11 @@ export interface ExamResponseArea {
|
|||||||
kind: ExamResponseAreaKind;
|
kind: ExamResponseAreaKind;
|
||||||
response_form: string | null;
|
response_form: string | null;
|
||||||
context_type?: string | null;
|
context_type?: string | null;
|
||||||
source: 'manual' | 'ai';
|
source: ExamTemplateSource;
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
confidence: number | null;
|
confidence: number | null;
|
||||||
|
mark_subtype?: ExamMarkSubtype | null;
|
||||||
|
derivation?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamBoundary {
|
export interface ExamBoundary {
|
||||||
@@ -121,14 +131,36 @@ export interface ExamBoundary {
|
|||||||
page_index: number;
|
page_index: number;
|
||||||
y: number;
|
y: number;
|
||||||
bounds: Record<string, number> | null;
|
bounds: Record<string, number> | null;
|
||||||
source: 'manual' | 'ai';
|
source: ExamTemplateSource;
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
derivation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamTemplateLayout {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
page_index: number;
|
||||||
|
role: string | null;
|
||||||
|
margin_left: number | null;
|
||||||
|
margin_right: number | null;
|
||||||
|
margin_top: number | null;
|
||||||
|
margin_bottom: number | null;
|
||||||
|
margins_enabled: boolean;
|
||||||
|
source: ExamTemplateSource;
|
||||||
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
derivation: string | null;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamTemplateDetail extends ExamTemplate {
|
export interface ExamTemplateDetail extends ExamTemplate {
|
||||||
questions: ExamQuestion[];
|
questions: ExamQuestion[];
|
||||||
response_areas: ExamResponseArea[];
|
response_areas: ExamResponseArea[];
|
||||||
boundaries: ExamBoundary[];
|
boundaries: ExamBoundary[];
|
||||||
|
layout: ExamTemplateLayout[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -152,6 +184,10 @@ export interface TemplateReplacePayload {
|
|||||||
spec_ref?: string | null;
|
spec_ref?: string | null;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
page?: number | null;
|
page?: number | null;
|
||||||
|
source?: ExamTemplateSource;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
derivation?: string | null;
|
||||||
}>;
|
}>;
|
||||||
response_areas: Array<{
|
response_areas: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -164,6 +200,8 @@ export interface TemplateReplacePayload {
|
|||||||
source?: 'manual' | 'ai';
|
source?: 'manual' | 'ai';
|
||||||
confirmed?: boolean;
|
confirmed?: boolean;
|
||||||
confidence?: number | null;
|
confidence?: number | null;
|
||||||
|
mark_subtype?: ExamMarkSubtype | null;
|
||||||
|
derivation?: string | null;
|
||||||
}>;
|
}>;
|
||||||
boundaries: Array<{
|
boundaries: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -172,8 +210,25 @@ export interface TemplateReplacePayload {
|
|||||||
page_index: number;
|
page_index: number;
|
||||||
y: number;
|
y: number;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
source?: 'manual' | 'ai';
|
source?: ExamTemplateSource;
|
||||||
confirmed?: boolean;
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
derivation?: string | null;
|
||||||
|
}>;
|
||||||
|
layout?: Array<{
|
||||||
|
id?: string;
|
||||||
|
page_index: number;
|
||||||
|
role?: string | null;
|
||||||
|
margin_left?: number | null;
|
||||||
|
margin_right?: number | null;
|
||||||
|
margin_top?: number | null;
|
||||||
|
margin_bottom?: number | null;
|
||||||
|
margins_enabled?: boolean;
|
||||||
|
source?: ExamTemplateSource;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
derivation?: string | null;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './m
|
|||||||
|
|
||||||
const template: ExamTemplateDetail = {
|
const template: ExamTemplateDetail = {
|
||||||
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
||||||
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [],
|
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [], layout: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('exam setup canvas serialization', () => {
|
describe('exam setup canvas serialization', () => {
|
||||||
@@ -51,14 +51,14 @@ describe('exam setup canvas serialization', () => {
|
|||||||
const shapes = shapesFromTemplate({
|
const shapes = shapesFromTemplate({
|
||||||
...template,
|
...template,
|
||||||
questions: [
|
questions: [
|
||||||
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null },
|
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 },
|
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
],
|
],
|
||||||
response_areas: [
|
response_areas: [
|
||||||
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null },
|
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null },
|
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
],
|
],
|
||||||
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
|
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true, confidence: null, derivation: null }],
|
||||||
})
|
})
|
||||||
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
||||||
expect(shapes.find((s) => s.kind === 'boundary')).toMatchObject({ id: 'b1', x: 0, y: 100, w: 780, h: 8 })
|
expect(shapes.find((s) => s.kind === 'boundary')).toMatchObject({ id: 'b1', x: 0, y: 100, w: 780, h: 8 })
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface CanvasPageGeometry { pageNumber: number; x: number; y: number;
|
|||||||
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
||||||
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
||||||
|
|
||||||
export interface CanvasBounds { x: number; y: number; w: number; h: number }
|
export interface CanvasBounds extends Record<string, number> { x: number; y: number; w: number; h: number }
|
||||||
|
|
||||||
export interface ExamCanvasShapeModel {
|
export interface ExamCanvasShapeModel {
|
||||||
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
|
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
|
||||||
@@ -111,10 +111,10 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
const qNum = bands.length + 1
|
const qNum = bands.length + 1
|
||||||
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
|
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
|
||||||
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
|
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
|
||||||
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
|
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {}, source: 'manual', confirmed: true, confidence: null, derivation: null })
|
||||||
bands.push({ questionId, top, bottom })
|
bands.push({ questionId, top, bottom })
|
||||||
for (const b of [top, bottom]) {
|
for (const b of [top, bottom]) {
|
||||||
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: 'manual', confirmed: true })
|
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: 'manual', confirmed: true, confidence: null, derivation: null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
||||||
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
|
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
|
||||||
partQuestionIds.set(part.id, qid)
|
partQuestionIds.set(part.id, qid)
|
||||||
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages) })
|
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: 'manual', confirmed: true, confidence: null, derivation: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
const response_areas: TemplateReplacePayload['response_areas'] = []
|
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||||
@@ -133,10 +133,10 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
||||||
if (!questionId) continue
|
if (!questionId) continue
|
||||||
const kind = region.kind as ExamCanvasRegionKind
|
const kind = region.kind as ExamCanvasRegionKind
|
||||||
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
|
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null, mark_subtype: null, derivation: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
|
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries, layout: template.layout ?? [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
||||||
@@ -150,12 +150,12 @@ export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPag
|
|||||||
}
|
}
|
||||||
for (const q of detail.questions ?? []) {
|
for (const q of detail.questions ?? []) {
|
||||||
if (q.is_container || !q.bounds) continue
|
if (q.is_container || !q.bounds) continue
|
||||||
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
|
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: (q.answer_type as ExamCanvasShapeModel['answerType']) ?? 'written', questionId: q.id })
|
||||||
}
|
}
|
||||||
for (const r of detail.response_areas ?? []) {
|
for (const r of detail.response_areas ?? []) {
|
||||||
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
|
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
|
||||||
const q = questions.get(r.question_id)
|
const q = questions.get(r.question_id)
|
||||||
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
|
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: (r.response_form as ExamCanvasShapeModel['responseForm']) ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
|
||||||
}
|
}
|
||||||
return shapes
|
return shapes
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user