diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 315b557..872f5c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,6 @@ jobs: with: node-version: 24.18.0 cache: npm - registry-url: https://registry.npmjs.org - run: npm install -g npm@11.16.0 - run: npm run toolchain:check - run: npm run lockfile:check diff --git a/.prettierignore b/.prettierignore index fe3192c..c658851 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,4 @@ dist package-lock.json playwright-report test-results +fonat-one-shot-mvp-spec-v*.md diff --git a/IMPLEMENTATION-DEVIATIONS.md b/IMPLEMENTATION-DEVIATIONS.md index ca7aaf5..8b71d53 100644 --- a/IMPLEMENTATION-DEVIATIONS.md +++ b/IMPLEMENTATION-DEVIATIONS.md @@ -7,26 +7,34 @@ This is a substantial runnable MVP modernized against the Version 4 specificatio - login, first-run bootstrap endpoint, server sessions, visible logout, cookie invalidation, disabled-user session invalidation; - stable shell, explicit presentation leave/complete actions, 404 recovery; - all six guided exercise types with Milkdown Crepe prompt/solution editors and learner preview; +- atomic blank-workspace onboarding for the first Subject, Group, learners, Location, Course, and Enrollments, plus guided organization editors; +- guided Concept/Resource material editing, actionable Library rows, named Relation management, and an Exercise catalogue with structured choice answers and Concept/evidence metadata; +- guided Annual Plan phases/coverage and a complete Lesson activity editor with explicit Exercise selection, teacher-only notes, scheduling fields, diagnostics, preview, and all presentation slide types; - 24 Grade 8 concepts, 18 authored exercises, five learner fixtures, multiple lessons, evidence, findings, assignments, assessment blueprint, and the complete ten-slide demo sequence; - live join code, scoped participant token, idempotent answer acceptance, polling, response table, teacher reveal, privacy-safe nickname leaderboard; - mutable assignment draft, immutable attempts, return/resubmit/accept flow; +- guided Assignment draft/issue editing with real Course/Exercise selectors, attempt policy, formatted review, editable feedback, and named learner context; - deterministic A/B assessment delivery with copied question snapshots, submission, grading, and grade entry; +- guided Assessment Blueprint slots, eligibility/shortfall preview, selected learner delivery, deterministic Concept-based filling, explained manual overrides, print styling, and linked follow-up findings; - feature-toggled Project route and original Mushroom Yard fixture; - React/Vite, Fastify, Zod, official MongoDB driver, TanStack Query/Table, React Hook Form, Milkdown Crepe, KaTeX, Mafs, dnd-kit, Playwright configuration; - Docker, Vercel, Render, OpenAPI generation, package manifests, brand assets, and required documentation set. +- file-backed searchable Markdown manual with GFM, KaTeX, stable contextual-help routes, complete teacher terminology, workflow guides, troubleshooting, tips, and link/glossary validation tests; +- accessible unsaved-change protection for setup and the core organization, material, exercise, annual-plan, lesson, assignment, and assessment editors, including refresh/close protection and explicit stay/discard actions; +- centralized connection and editor save-state feedback, typed retryable offline/network failures, duplicate-save prevention, and an authentication-safe reconnect screen without automatic offline writes; ## Partial or deferred from the specification 1. **Persistence granularity:** MongoDB stores a bounded optimistic workspace snapshot, not separate aggregate collections with transaction-specific repositories. This is the largest architectural deviation. It preserves end-to-end behavior but is unsuitable for large collections and high concurrency. 2. **Package ZIP staging:** manifests and validation exist, but safe ZIP expansion, MIME checks, staging diff, transactional package update, and round-trip export are not complete. 3. **Revision model:** published exercise revision counters and immutable assessment snapshots exist. Full canonical revision records, scheduled impact notices, package-owned forks, and cross-workflow revision resolution are incomplete. -4. **Blank onboarding UI:** an Admin reset can create a blank workspace, and CRUD surfaces can rebuild it. A dedicated step-by-step onboarding wizard with foundation-package application is incomplete. -5. **Organization depth:** group/course/location creation works through generic guided forms, but historical course roster resolution, explicit inclusion/exclusion, and full timetable recurrence/override editing are simplified. -6. **Assessment sophistication:** deterministic delivery, A/B labels, grading, and grades work. Slot ranking, meaningful equivalent alternatives, shortfall remediation UI, regrade preview, print stylesheet, and five analyzers are incomplete. +4. **Blank onboarding depth:** the guided setup creates the first real Subject, Group, learners, Location, Course, and Enrollments atomically without demo IDs. Foundation-package application and embedding the first Concept, Exercise, and Lesson directly in the wizard remain incomplete; the Today quick-start actions lead into their real editors instead. +5. **Organization depth:** guided Group, learner, Course, and Location editors work, but historical roster resolution, explicit inclusion/exclusion, bulk roster correction, and full timetable recurrence/override editing remain simplified. +6. **Assessment sophistication:** deterministic Concept-slot delivery, A/B labels, shortfall preview, grading, explained manual override, Grade Entries, print styling, and basic follow-up Findings work. Broader source filters, meaningful equivalent ranking beyond deterministic eligible selection, interactive shortfall remediation, regrade impact preview, and the full five-analyzer cohort pipeline remain incomplete. 7. **Content package volume:** the runtime demo contains the important Grade 8 catalogue. The on-disk packages are contract examples rather than complete exported mirrors of every fixture. 8. **Authorization and CSRF:** role data, protected routes, secure cookie policy, origin checks, and rate limits exist. Fine-grained capabilities and an explicit CSRF token mechanism are incomplete. 9. **Server route decomposition:** feature ownership folders are present, but the Fastify application factory remains large. Extract routes/use cases when a second implementation cycle begins. -10. **Real Mongo and browser verification:** configuration and tests are included. Docker was unavailable in the generation environment. Playwright’s bundled Chromium could not be downloaded because external DNS was unavailable, while the system Chromium is managed with a machine-wide `URLBlocklist: ["*"]`; therefore the browser journey remains unverified here. The production server was instead smoke-tested over HTTP for health, static delivery, login, authenticated Today data, and session cookies. +10. **Real Mongo and browser verification:** configuration and tests are included. Docker was unavailable in the generation environment. Playwright’s bundled Chromium is not installed and could not be downloaded because external DNS was unavailable, so the checked-in Playwright suite could not launch here. The same rendered path was manually verified with the in-app browser for login, exercise hydration, manual search and contextual help, presentation pause/escape, logout, unsaved-change handling, and online save-state feedback; the production server was also smoke-tested over HTTP for health, static delivery, authenticated Today data, and session cookies. The checked-in Playwright connection-loss scenario covers offline indication, retained form state, failed-save feedback, reconnection, and retry, but could only be listed rather than launched in this environment. 11. **Localization:** Hungarian UI is the default, with English content fields in package contracts. Full i18next namespaces and complete English UI are not implemented. 12. **Assets:** bundled SVG and external-link records work. Local filesystem rich-media provider and YouTube structured record are only documented foundations. diff --git a/README.md b/README.md index a20ebcb..c9c0ccb 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Fonat is a source-available, single-teacher-first educational operating system. ## Fastest local start -Requirements: Node.js **24.18.0**, npm **11.16.0**, and optionally Docker. Local, CI, and Docker use those exact tested versions; package engines permit compatible Node 24/npm 11 releases for hosted builds. +Requirements: Node.js **24.18.0**, npm **11.16.0**, and optionally Docker. Local version files, CI, Docker, and `packageManager` use those exact tested versions. The package engine intentionally selects compatible Node 24 releases so hosted platforms such as Vercel can supply their supported patch version. ```bash cp .env.example .env @@ -22,6 +22,10 @@ Open `http://localhost:5173`. Demo login: The development profile uses memory persistence when `MONGODB_URI` is empty. Set a MongoDB replica-set URI to exercise persistent compare-and-swap storage. +## Teacher workflow + +After login, a blank workspace opens the guided setup for the first subject, group, learners, location, course, and enrollments. The main navigation then provides guided editors for learning materials, all six exercise types, annual plans, lessons, assignments, assessment blueprints, grading, and findings. Core teacher editors protect unsaved work with explicit stay/discard actions and a refresh/close warning. They also expose consistent save progress, prevent duplicate writes, retain local form state after a failed save, and distinguish connection outages from expired authentication without attempting offline writes. The built-in **Útmutató** is a searchable Markdown manual; each core editor links to its matching how-to article. See `IMPLEMENTATION-DEVIATIONS.md` for the intentionally deferred depth beyond this teacher-usable MVP. + ## Root commands | Command | Purpose | diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 0d94a4e..137d875 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -11,6 +11,7 @@ import fastifyStatic from "@fastify/static"; import { existsSync } from "node:fs"; import path from "node:path"; import bcrypt from "bcryptjs"; +import { z } from "zod"; import { authenticate, createDemoState, @@ -63,6 +64,30 @@ const collectionMap = { projects: "projects", } as const; type CollectionRoute = keyof typeof collectionMap; +type AssessmentSlot = { + id: string; + conceptId: string; + points?: number; + difficultyMin?: number; + difficultyMax?: number; +}; + +const eligibleForSlot = (state: WorkspaceState, slot: AssessmentSlot) => + state.exercises.filter( + (exercise) => + exercise.lifecycle === "published" && + exercise.conceptIds.includes(slot.conceptId) && + (slot.difficultyMin == null || + exercise.difficulty >= slot.difficultyMin) && + (slot.difficultyMax == null || exercise.difficulty <= slot.difficultyMax), + ); + +const stableIndex = (value: string, length: number) => { + let hash = 0; + for (const character of value) + hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + return length ? hash % length : 0; +}; const publicPrefixes = [ "/api/auth/status", "/api/auth/bootstrap", @@ -312,6 +337,113 @@ export async function createApp( app.get("/api/workspace/summary", async () => ok(summarize(store.snapshot())), ); + app.get("/api/onboarding/status", async () => { + const state = store.snapshot(); + return ok({ + complete: + state.courses.length > 0 && + state.learnerGroups.length > 0 && + state.learners.length > 0, + counts: { + subjects: state.subjects.length, + groups: state.learnerGroups.length, + learners: state.learners.length, + locations: state.locations.length, + courses: state.courses.length, + }, + }); + }); + app.post("/api/onboarding/complete", async (req, reply) => { + const parsed = z + .object({ + subjectTitle: z.string().min(2), + groupTitle: z.string().min(1), + schoolYear: z.string().min(4), + learnerNames: z.array(z.string().min(1)).min(1), + locationTitle: z.string().min(2), + room: z.string().optional(), + courseTitle: z.string().min(2), + timezone: z.string().min(1).default(config.SCHOOL_TIMEZONE), + }) + .safeParse(req.body); + if (!parsed.success) + return reply + .code(400) + .send( + err( + "VALIDATION", + "onboarding.invalid", + false, + parsed.error.flatten().fieldErrors as Record, + ), + ); + const created = await store.mutate((state) => { + const now = clock.now().toISOString(); + const subject = { + id: id("subject"), + title: parsed.data.subjectTitle, + concurrencyVersion: 1, + createdAt: now, + updatedAt: now, + }; + const group = { + id: id("group"), + title: parsed.data.groupTitle, + schoolYear: parsed.data.schoolYear, + concurrencyVersion: 1, + createdAt: now, + updatedAt: now, + }; + const location = { + id: id("location"), + title: parsed.data.locationTitle, + room: parsed.data.room, + concurrencyVersion: 1, + createdAt: now, + updatedAt: now, + }; + const learners = parsed.data.learnerNames.map((name) => ({ + id: id("learner"), + title: name, + displayPseudonym: name, + administrativeIdentity: {}, + concurrencyVersion: 1, + createdAt: now, + updatedAt: now, + })); + const course = { + id: id("course"), + title: parsed.data.courseTitle, + subjectId: subject.id, + learnerGroupIds: [group.id], + defaultLocationId: location.id, + timezone: parsed.data.timezone, + status: "active", + concurrencyVersion: 1, + createdAt: now, + updatedAt: now, + }; + const enrollments = learners.map((learner) => ({ + id: id("enrollment"), + title: `${learner.title} – ${group.title}`, + learnerId: learner.id, + learnerGroupId: group.id, + status: "active", + startDate: now.slice(0, 10), + concurrencyVersion: 1, + createdAt: now, + updatedAt: now, + })); + state.subjects.push(subject); + state.learnerGroups.push(group); + state.locations.push(location); + state.learners.push(...learners); + state.enrollments.push(...enrollments); + state.courses.push(course); + return { subject, group, location, learners, course }; + }); + return reply.code(201).send(ok(created)); + }); app.get("/api/today", async () => { const state = store.snapshot(); return ok({ @@ -399,6 +531,58 @@ export async function createApp( body.options = parsed.data.options; Object.assign(body, parsed.data); } + if (route === "relations") { + const result = await store.mutate((state) => { + const sourceId = String(body.sourceId || ""); + const targetId = String(body.targetId || ""); + const relationType = String(body.type || ""); + const relationTypes = new Set([ + "requires", + "covers", + "alternative-to", + "extends", + ]); + const nodeIds = new Set(state.nodes.map((node) => node.id)); + if ( + !sourceId || + !targetId || + !relationTypes.has(relationType) || + sourceId === targetId || + !nodeIds.has(sourceId) || + !nodeIds.has(targetId) + ) + return err("VALIDATION", "relation.invalid", false, { + targetId: ["Choose a different existing material"], + }); + if ( + state.relations.some( + (relation) => + relation.sourceId === sourceId && + relation.targetId === targetId && + relation.type === relationType && + relation.lifecycle !== "archived", + ) + ) + return err("VALIDATION", "relation.duplicate", false, { + targetId: ["This active relation already exists"], + }); + const value = { + ...body, + id: body.id || id("relation"), + sourceId, + targetId, + type: relationType, + concurrencyVersion: 1, + createdAt: clock.now().toISOString(), + updatedAt: clock.now().toISOString(), + }; + state.relations.push(value); + return ok(value); + }); + return result.ok + ? reply.code(201).send(result) + : reply.code(400).send(result); + } const item = await store.mutate((state) => { const value = { ...body, @@ -423,19 +607,44 @@ export async function createApp( const current = list[index]!; if (expected && Number(current.concurrencyVersion) !== expected) return err("CONFLICT", "common.staleWrite", true); - const next = { + const candidate = { ...current, ...(req.body as any), id: current.id, concurrencyVersion: Number(current.concurrencyVersion || 1) + 1, updatedAt: clock.now().toISOString(), }; - list[index] = next; - return ok(next); + if (route === "exercises") { + const parsed = exerciseSchema.safeParse(candidate); + if (!parsed.success) + return err( + "VALIDATION", + "exercise.invalid", + false, + parsed.error.flatten().fieldErrors as Record, + ); + Object.assign(candidate, parsed.data); + if ( + current.lifecycle !== "published" && + candidate.lifecycle === "published" + ) + candidate.currentRevision = + Number(current.currentRevision || 0) + 1; + } + list[index] = candidate; + return ok(candidate); }); return result.ok ? result - : reply.code(result.error.code === "CONFLICT" ? 409 : 404).send(result); + : reply + .code( + result.error.code === "CONFLICT" + ? 409 + : result.error.code === "VALIDATION" + ? 400 + : 404, + ) + .send(result); }); } @@ -795,8 +1004,57 @@ export async function createApp( }); app.get("/api/submissions", async () => ok(store.snapshot().submissions)); + app.post("/api/assessments/preview", async (req, reply) => { + const blueprintId = String((req.body as any)?.blueprintId || ""); + const state = store.snapshot(); + const blueprint = state.assessmentBlueprints.find( + (item) => item.id === blueprintId, + ) as any; + if (!blueprint) + return reply + .code(404) + .send(err("NOT_FOUND", "assessment.blueprintNotFound")); + const slots = (blueprint.slots || []) as AssessmentSlot[]; + return ok({ + blueprintId, + totalPoints: slots.reduce( + (total, slot) => total + Number(slot.points || 0), + 0, + ), + slots: slots.map((slot) => { + const eligible = eligibleForSlot(state, slot); + return { + ...slot, + eligibleCount: eligible.length, + candidateTitles: eligible + .slice(0, 5) + .map((exercise) => exercise.title), + shortfall: eligible.length === 0, + }; + }), + }); + }); + app.post("/api/assessments/generate", async (req, reply) => { const b = req.body as any; + const source = store.snapshot(); + const sourceBlueprint = source.assessmentBlueprints.find( + (item) => item.id === String(b.blueprintId), + ) as any; + if (!sourceBlueprint) + return reply + .code(404) + .send(err("NOT_FOUND", "assessment.blueprintNotFound")); + const sourceSlots = (sourceBlueprint.slots || []) as AssessmentSlot[]; + const shortfalls = sourceSlots.filter( + (slot) => eligibleForSlot(source, slot).length === 0, + ); + if (shortfalls.length) + return reply.code(400).send( + err("VALIDATION", "assessment.slotShortfall", false, { + slots: shortfalls.map((slot) => slot.id), + }), + ); const generated = await store.mutate((state) => { const blueprint = state.assessmentBlueprints.find( (x) => x.id === String(b.blueprintId), @@ -816,9 +1074,18 @@ export async function createApp( const learnerIds = (b.learnerIds || state.learners.slice(0, 2).map((x) => x.id)) as string[]; const deliveries = learnerIds.map((learnerId, index) => { - const exerciseIds = state.exercises - .slice(index, index + 6) - .map((e) => e.id); + const slots = (blueprint.slots || []) as AssessmentSlot[]; + const selectedExercises = slots.length + ? slots.map((slot, slotIndex) => { + const eligible = eligibleForSlot(state, slot); + return eligible[ + stableIndex( + `${assessment.seed}:${learnerId}:${index}:${slotIndex}`, + eligible.length, + ) + ]; + }) + : state.exercises.slice(index, index + 6); const d = { id: id("delivery"), title: `${assessment.title} ${index % 2 ? "B" : "A"}`, @@ -826,8 +1093,9 @@ export async function createApp( learnerId, variant: index % 2 ? "B" : "A", seed: `${assessment.seed}:${learnerId}`, - exerciseSnapshots: exerciseIds.map((eid) => - structuredClone(state.exercises.find((e) => e.id === eid)), + blueprintSnapshot: structuredClone(blueprint), + exerciseSnapshots: selectedExercises.map((exercise) => + structuredClone(exercise), ), answers: {}, status: "available", @@ -870,6 +1138,7 @@ export async function createApp( }, ); app.post("/api/assessment-deliveries/:id/grade", async (req, reply) => { + const body = req.body as any; const result = await store.mutate((state) => { const d = state.assessmentDeliveries.find( (x) => x.id === (req.params as any).id, @@ -878,10 +1147,32 @@ export async function createApp( let correct = 0; for (const ex of d.exerciseSnapshots as Exercise[]) if (gradeExercise(ex, d.answers?.[ex.id]) === true) correct++; + const automaticPercent = Math.round( + (100 * correct) / Math.max(1, d.exerciseSnapshots.length), + ); + const manualPercent = + body.manualPercent == null ? undefined : Number(body.manualPercent); + if ( + manualPercent != null && + (!Number.isFinite(manualPercent) || + manualPercent < 0 || + manualPercent > 100 || + !String(body.overrideReason || "").trim()) + ) + return err("VALIDATION", "assessment.overrideInvalid", false, { + overrideReason: ["Required for manual override"], + }); d.score = { correct, total: d.exerciseSnapshots.length, - percent: Math.round((100 * correct) / d.exerciseSnapshots.length), + percent: manualPercent ?? automaticPercent, + automaticPercent, + ...(manualPercent != null + ? { + manualOverride: true, + overrideReason: String(body.overrideReason), + } + : {}), }; d.status = "graded"; state.grades.push({ @@ -901,9 +1192,23 @@ export async function createApp( : 1, recordedAt: clock.now().toISOString(), }); + state.findings.push({ + id: id("finding"), + title: + d.score.percent < 50 + ? `${d.title}: utánkövetés javasolt` + : `${d.title}: értékelés elkészült`, + deliveryId: d.id, + learnerId: d.learnerId, + severity: d.score.percent < 50 ? "warning" : "info", + explanation: `${d.score.percent}% eredmény ${d.exerciseSnapshots.length} feladat alapján.`, + createdAt: clock.now().toISOString(), + }); return ok(d); }); - return result.ok ? result : reply.code(404).send(result); + return result.ok + ? result + : reply.code(result.error.code === "VALIDATION" ? 400 : 404).send(result); }); app.post("/api/packages/demo-reset", async (req) => { @@ -923,26 +1228,6 @@ export async function createApp( ); return store.snapshot(); }); - app.get("/api/guide", async () => - ok({ - title: "Fonat Guide", - sections: [ - { - title: "Első lépések", - body: "A Today oldalról nyisd meg a bemutató órát, vagy hozz létre saját tartalmat a Könyvtárban.", - }, - { - title: "Élő óra", - body: "A tanári vezérlő kódját a tanulók a /join oldalon adják meg.", - }, - { - title: "Biztonság", - body: "A Kilépés megszünteti a szerveroldali munkamenetet.", - }, - ], - }), - ); - const webDist = path.resolve(process.cwd(), "apps/web/dist"); if (existsSync(webDist)) { await app.register(fastifyStatic, { root: webDist, prefix: "/" }); diff --git a/apps/web/package.json b/apps/web/package.json index 521a3ac..2e6c9f7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "@dnd-kit/utilities": "3.2.2", "@hookform/resolvers": "5.4.0", "@milkdown/crepe": "7.21.3", + "@milkdown/kit": "7.21.3", "@radix-ui/themes": "3.3.0", "@tanstack/react-query": "5.101.2", "@tanstack/react-table": "8.21.3", @@ -26,6 +27,8 @@ "react-markdown": "10.1.0", "react-router-dom": "7.18.1", "rehype-katex": "7.0.1", + "rehype-slug": "6.0.0", + "remark-gfm": "4.0.1", "remark-math": "6.0.0", "zod": "4.4.3" }, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3dc148c..d1dba17 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,6 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { api } from "./api"; +import { ApiError, api } from "./api"; import { Shell } from "./components/Shell"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { LoginPage } from "./pages/LoginPage"; @@ -22,6 +22,10 @@ import { ProjectsPage } from "./pages/ProjectsPage"; import { GuidePage } from "./pages/GuidePage"; import { MathPlotPage } from "./pages/MathPlotPage"; import { NodeDetailPage } from "./pages/NodeDetailPage"; +import { MaterialEditorPage } from "./pages/MaterialEditorPage"; +import { OrganizationEditorPage } from "./pages/OrganizationEditorPage"; +import { SetupPage } from "./pages/SetupPage"; +import { AnnualPlanEditorPage } from "./pages/AnnualPlanEditorPage"; import { NotFoundPage } from "./pages/NotFoundPage"; function Protected() { const location = useLocation(); @@ -31,8 +35,25 @@ function Protected() { retry: false, }); if (me.isLoading) return
Fonat betöltése…
; - if (me.error) - return ; + if (me.error) { + if (!(me.error instanceof ApiError)) throw me.error; + const authenticationFailed = [401, 403].includes(me.error.status); + if (authenticationFailed) + return ; + return ( +
+ Kapcsolódás szünetel +

A Fonat most nem éri el a szervert

+

+ A munkamenetedet nem tekintjük lejártnak hálózati vagy szerverhiba + miatt. Ellenőrizd a kapcsolatot, majd próbáld újra. +

+ +
+ ); + } return ; } export function App() { @@ -53,31 +74,72 @@ export function App() { } /> }> } /> + } /> } /> } /> + } /> + } /> } /> + } + /> } /> } /> } /> } /> } /> } /> + } + /> + } + /> } /> + } + /> + } + /> } /> + } + /> + } + /> } /> + } + /> + } + /> } /> + } /> + } /> } /> } /> } /> @@ -85,6 +147,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index a56f3f2..bff657a 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -9,15 +9,56 @@ export type Result = retryable: boolean; }; }; + +export class ApiError extends Error { + constructor( + message: string, + readonly code: string, + readonly status: number, + readonly retryable: boolean, + readonly fieldErrors: Record = {}, + readonly technicalReference?: string, + ) { + super(message); + this.name = "ApiError"; + } +} + export async function api( path: string, options: RequestInit = {}, ): Promise { - const response = await fetch(path, { - credentials: "include", - headers: { "content-type": "application/json", ...(options.headers || {}) }, - ...options, - }); + if (typeof navigator !== "undefined" && navigator.onLine === false) { + throw new ApiError( + "Nincs hálózati kapcsolat. A módosítások nem lettek mentve.", + "OFFLINE", + 0, + true, + ); + } + + let response: Response; + try { + response = await fetch(path, { + credentials: "include", + headers: { + "content-type": "application/json", + ...(options.headers || {}), + }, + ...options, + }); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") + throw error; + throw new ApiError( + "A szerver nem érhető el. Ellenőrizd a kapcsolatot, majd próbáld újra.", + "NETWORK", + 0, + true, + {}, + error instanceof Error ? error.message : undefined, + ); + } const data = await response.json().catch(() => ({ ok: false, error: { @@ -26,8 +67,17 @@ export async function api( retryable: true, }, })); - if (!response.ok || data?.ok === false) - throw new Error(data?.error?.messageKey || `HTTP ${response.status}`); + if (!response.ok || data?.ok === false) { + const error = data?.error; + throw new ApiError( + error?.messageKey || `HTTP ${response.status}`, + error?.code || "HTTP", + response.status, + Boolean(error?.retryable), + error?.fieldErrors || {}, + error?.technicalReference, + ); + } return data?.ok === true ? data.value : data; } export const post = (path: string, body: unknown) => diff --git a/apps/web/src/components/ConnectionStatus.tsx b/apps/web/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000..8066c62 --- /dev/null +++ b/apps/web/src/components/ConnectionStatus.tsx @@ -0,0 +1,76 @@ +import { useSyncExternalStore } from "react"; + +function subscribeToConnectionStatus(onStoreChange: () => void) { + if (typeof window === "undefined") return () => undefined; + window.addEventListener("online", onStoreChange); + window.addEventListener("offline", onStoreChange); + return () => { + window.removeEventListener("online", onStoreChange); + window.removeEventListener("offline", onStoreChange); + }; +} + +function getOnlineSnapshot() { + return typeof navigator === "undefined" || navigator.onLine !== false; +} + +export function useOnlineStatus() { + return useSyncExternalStore( + subscribeToConnectionStatus, + getOnlineSnapshot, + () => true, + ); +} + +export function ConnectionStatus() { + const online = useOnlineStatus(); + if (online) return null; + + return ( +
+ Nincs hálózati kapcsolat. + + A nem mentett változtatások a szerkesztőben maradnak. Mentéshez várd + meg, amíg helyreáll a kapcsolat. + +
+ ); +} + +type EditorSaveStatusProps = { + dirty: boolean; + pending: boolean; + saved: boolean; +}; + +export function EditorSaveStatus({ + dirty, + pending, + saved, +}: EditorSaveStatusProps) { + const online = useOnlineStatus(); + const status = pending + ? { label: "Mentés folyamatban…", tone: "pending" } + : dirty && !online + ? { + label: "Nincs kapcsolat — a módosítások még nincsenek mentve.", + tone: "warning", + } + : dirty + ? { label: "Nem mentett módosítások", tone: "dirty" } + : saved + ? { label: "Minden változtatás mentve", tone: "saved" } + : null; + + if (!status) return null; + return ( +

+

+ ); +} diff --git a/apps/web/src/components/ContentEditor.tsx b/apps/web/src/components/ContentEditor.tsx index 395b151..d1ebc62 100644 --- a/apps/web/src/components/ContentEditor.tsx +++ b/apps/web/src/components/ContentEditor.tsx @@ -1,34 +1,74 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Crepe } from "@milkdown/crepe"; +import { replaceAll } from "@milkdown/kit/utils"; export function ContentEditor({ value, onChange, readOnly = false, + ariaLabel, }: { value: string; onChange: (value: string) => void; readOnly?: boolean; + ariaLabel?: string; }) { const root = useRef(null); + const crepe = useRef(undefined); + const currentMarkdown = useRef(value); + const applyingExternalValue = useRef(false); const latest = useRef(onChange); + const [ready, setReady] = useState(false); latest.current = onChange; useEffect(() => { if (!root.current) return; - const crepe = new Crepe({ root: root.current, defaultValue: value }); - crepe.on((listener) => { - listener.markdownUpdated((_ctx, markdown) => latest.current(markdown)); + const instance = new Crepe({ + root: root.current, + defaultValue: currentMarkdown.current, }); - void crepe.create().then(() => { - if (readOnly) root.current?.setAttribute("data-readonly", "true"); + instance.on((listener) => { + listener.markdownUpdated((_ctx, markdown) => { + currentMarkdown.current = markdown; + if (!applyingExternalValue.current) latest.current(markdown); + }); + }); + void instance.create().then(() => { + crepe.current = instance; + instance.setReadonly(readOnly); + const loadedMarkdown = instance.getMarkdown(); + if (loadedMarkdown !== currentMarkdown.current) { + applyingExternalValue.current = true; + instance.editor.action(replaceAll(currentMarkdown.current)); + queueMicrotask(() => { + applyingExternalValue.current = false; + }); + } + setReady(true); }); return () => { - void crepe.destroy(); + crepe.current = undefined; + void instance.destroy(); }; }, []); + useEffect(() => { + const instance = crepe.current; + if (!instance || value === currentMarkdown.current) return; + currentMarkdown.current = value; + applyingExternalValue.current = true; + instance.editor.action(replaceAll(value)); + queueMicrotask(() => { + applyingExternalValue.current = false; + }); + }, [value]); + useEffect(() => { + crepe.current?.setReadonly(readOnly); + }, [readOnly]); return (
); } diff --git a/apps/web/src/components/Markdown.tsx b/apps/web/src/components/Markdown.tsx index e9c0245..9c2869f 100644 --- a/apps/web/src/components/Markdown.tsx +++ b/apps/web/src/components/Markdown.tsx @@ -1,10 +1,15 @@ import ReactMarkdown from "react-markdown"; import remarkMath from "remark-math"; +import remarkGfm from "remark-gfm"; import rehypeKatex from "rehype-katex"; +import rehypeSlug from "rehype-slug"; export function Markdown({ children }: { children?: string }) { return (
- + {children || ""}
diff --git a/apps/web/src/components/Shell.tsx b/apps/web/src/components/Shell.tsx index fc9c99f..75643d0 100644 --- a/apps/web/src/components/Shell.tsx +++ b/apps/web/src/components/Shell.tsx @@ -7,20 +7,45 @@ import { } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { post } from "../api"; +import { ConnectionStatus } from "./ConnectionStatus"; import { Logo } from "./Logo"; -const nav: Array<[string, string]> = [ - ["/", "Ma"], - ["/timetable", "Órarend"], - ["/library", "Könyvtár"], - ["/exercises/new", "Új feladat"], - ["/lessons", "Óratervek"], - ["/assignments", "Feladatok"], - ["/assessments", "Felmérések"], - ["/insights", "Elemzések"], - ["/projects", "Projektek"], - ["/admin", "Admin"], - ["/guide", "Útmutató"], -]; +const nav = [ + { + label: "Napi munka", + items: [ + ["/", "Ma"], + ["/timetable", "Órarend"], + ["/lessons", "Óratervek"], + ["/assignments", "Kiosztások"], + ["/assessments", "Felmérések"], + ["/insights", "Elemzések"], + ], + }, + { + label: "Tartalom", + items: [ + ["/library", "Könyvtár"], + ["/exercises", "Gyakorlófeladatok"], + ["/annual-plans", "Éves tervek"], + ["/projects", "Projektek"], + ], + }, + { + label: "Munkatér", + items: [ + ["/courses", "Kurzusok"], + ["/groups", "Csoportok"], + ["/learners", "Tanulók"], + ["/locations", "Helyszínek"], + ["/admin", "Admin"], + ["/guide", "Útmutató"], + ], + }, +] satisfies Array<{ label: string; items: Array<[string, string]> }>; + +const routeLabels: Record = Object.fromEntries( + nav.flatMap((group) => group.items), +); export function Shell() { const location = useLocation(); const navigate = useNavigate(); @@ -37,14 +62,20 @@ export function Shell() {
@@ -59,7 +90,14 @@ export function Shell() {
Aktuális hely - {location.pathname === "/" ? "Ma" : location.pathname} + {location.pathname === "/" + ? "Ma" + : routeLabels[ + Object.keys(routeLabels).find( + (route) => + route !== "/" && location.pathname.startsWith(route), + ) || "" + ] || "Szerkesztés"}
+
diff --git a/apps/web/src/components/UnsavedChangesGuard.tsx b/apps/web/src/components/UnsavedChangesGuard.tsx new file mode 100644 index 0000000..4eef42b --- /dev/null +++ b/apps/web/src/components/UnsavedChangesGuard.tsx @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useBeforeUnload, useBlocker } from "react-router-dom"; + +export function useUnsavedChanges(when: boolean) { + const allowNavigationRef = useRef(false); + const stayButtonRef = useRef(null); + const [pendingDiscard, setPendingDiscard] = useState<(() => void) | null>( + null, + ); + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + when && + !allowNavigationRef.current && + `${currentLocation.pathname}${currentLocation.search}` !== + `${nextLocation.pathname}${nextLocation.search}`, + ); + + useBeforeUnload( + useCallback( + (event) => { + if (!when || allowNavigationRef.current) return; + event.preventDefault(); + event.returnValue = ""; + }, + [when], + ), + ); + + const stay = () => { + setPendingDiscard(null); + if (blocker.state === "blocked") blocker.reset(); + }; + const discard = () => { + if (pendingDiscard) { + const action = pendingDiscard; + setPendingDiscard(null); + action(); + return; + } + if (blocker.state === "blocked") blocker.proceed(); + }; + const blocked = blocker.state === "blocked" || pendingDiscard !== null; + + useEffect(() => { + if (!blocked) return; + stayButtonRef.current?.focus(); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") stay(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [blocked, blocker, pendingDiscard]); + + const allowNavigation = () => { + allowNavigationRef.current = true; + window.setTimeout(() => { + allowNavigationRef.current = false; + }, 0); + }; + const requestDiscard = (action: () => void) => { + if (!when) { + action(); + return; + } + setPendingDiscard(() => action); + }; + + return { + allowNavigation, + requestDiscard, + confirmation: blocked ? ( +
+
+

Nem mentett módosítások

+

+ Ha elhagyod ezt az oldalt, a még el nem mentett változtatások + elvesznek. +

+
+ + +
+
+
+ ) : null, + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 955fb70..889b47c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; +import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Theme } from "@radix-ui/themes"; import "@radix-ui/themes/styles.css"; @@ -13,6 +13,7 @@ import { App } from "./App"; const client = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 2_000 } }, }); +const router = createBrowserRouter([{ path: "*", element: }]); ReactDOM.createRoot(document.getElementById("root")!).render( - - - + , diff --git a/apps/web/src/manual/hu/assign-and-review.md b/apps/web/src/manual/hu/assign-and-review.md new file mode 100644 index 0000000..35082f9 --- /dev/null +++ b/apps/web/src/manual/hu/assign-and-review.md @@ -0,0 +1,20 @@ +# Kiosztás és javítás + +## Kiosztás létrehozása + +1. Nyisd meg a **Kiosztások** oldalt. +2. Adj címet és tanulói útmutatót. +3. Válassz kurzust és egy vagy több közzétett gyakorlófeladatot. +4. Állíts határidőt, próbálkozásszámot, visszajelzés-kiadást és bizonyítékpolitikát. +5. Ments piszkozatot, vagy válaszd a **Kiosztás** műveletet. + +## Javítás + +1. A beadási kártyán ellenőrizd a tanulót, a sorszámozott próbálkozást és a formázott választ. +2. Írj egyértelmű, végrehajtható visszajelzést. +3. **Visszaküldés** esetén a tanuló új próbálkozást adhat be; a korábbi nem változik meg. +4. **Elfogadás** esetén a próbálkozás lezárul és tanulási bizonyíték keletkezik. + +> Jó visszajelzés: „Írd le az ellenőrző behelyettesítést is.” Kevésbé hasznos: „Próbáld újra.” + +Az előzmények célja nem a hibák büntetése, hanem a javítás útjának megőrzése. diff --git a/apps/web/src/manual/hu/build-a-lesson.md b/apps/web/src/manual/hu/build-a-lesson.md new file mode 100644 index 0000000..17360c9 --- /dev/null +++ b/apps/web/src/manual/hu/build-a-lesson.md @@ -0,0 +1,33 @@ +# Óraterv összeállítása és bemutatása + +## Összeállítás + +1. Válassz kurzust, dátumot, tervezett időt és szükség esetén eltérő helyszínt. +2. Adj tevékenységeket az órafolyamhoz. +3. Kattints egy tevékenységre, és szerkeszd a címét, tartalmát és típusspecifikus mezőit. +4. Gyakorlófeladatnál és élő kérdésnél mindig válassz konkrét, közzétett feladatot. +5. A nyilakkal vagy húzással rendezd a sorrendet. +6. Javítsd a közzétételt blokkoló diagnosztikai hibákat. +7. Ments piszkozatot, majd tedd közzé. + +## Ajánlott ritmus + +| Rész | Példa idő | +| ----------------------------- | ---------: | +| Ráhangolás és előfeltétel | 5 perc | +| Új fogalom vagy felfedezés | 10–15 perc | +| Vezetett gyakorlás | 10 perc | +| Élő ellenőrzés és megbeszélés | 5–8 perc | +| Lezárás és következő lépés | 3–5 perc | + +## Tanári és kivetített tartalom + +A **tanári jegyzet** soha nem kerül a kivetített nézetbe. A megoldást és az élő eredményt csak a tanár fedje fel. Bemutatás előtt ellenőrizd a sorrendet és a tanulói előnézetet. + +## Szünet, folytatás, befejezés + +- **Szünet és kilépés:** visszatér a munkatérbe, az óra folytatható. +- **Folytatás:** a Ma oldalon a folyamatban lévő óránál érhető el. +- **Befejezés:** végleg lezárja az órafuttatást. + +Ne használd a böngésző bezárását óraállapot-kezelésként; a vezérlő látható műveleteit használd. diff --git a/apps/web/src/manual/hu/create-an-assessment.md b/apps/web/src/manual/hu/create-an-assessment.md new file mode 100644 index 0000000..984f6b7 --- /dev/null +++ b/apps/web/src/manual/hu/create-an-assessment.md @@ -0,0 +1,32 @@ +# Felmérés készítése és értékelése + +## Sablon + +1. Adj címet, kurzust és tanulói útmutatót. +2. Hozz létre követelményhelyeket. +3. Minden helyhez válassz fogalmat és pontértéket. +4. Tedd közzé a sablont. + +## Jogosultság és hiány + +A generálás előtt válaszd a **Jogosultság és hiány előnézete** műveletet. A Fonat megmutatja, hány közzétett feladat felel meg minden helynek. + +Ha hiány van: + +- készíts vagy tegyél közzé megfelelő feladatot; +- módosítsd a fogalmat vagy a követelményt; +- csökkentsd vagy halaszd a helyet. + +A rendszer nem lazítja csendben a feltételeket. + +## Kézbesítés + +1. Válaszd ki a tanulókat. +2. Szükség esetén adj reprodukálható magot a speciális beállításoknál. +3. Készíts stabil A/B kézbesítéseket. + +Minden tanuló saját megváltoztathatatlan pillanatképet kap. A későbbi feladatszerkesztés nem írja át. + +## Értékelés és felülbírálás + +A támogatott feladattípusokat a Fonat automatikusan ellenőrzi. Kézi százalék megadásakor kötelező indoklást írni. Az indok a pontszám eredetének része; a megállapítás önmagában nem módosít jegyet. diff --git a/apps/web/src/manual/hu/create-exercises.md b/apps/web/src/manual/hu/create-exercises.md new file mode 100644 index 0000000..5ae349a --- /dev/null +++ b/apps/web/src/manual/hu/create-exercises.md @@ -0,0 +1,26 @@ +# Gyakorlófeladat készítése + +1. Nyisd meg a **Gyakorlófeladatok** oldalt és válaszd az **Új elem** műveletet. +2. Válassz feladattípust, írj címet és feladatszöveget. +3. Adj meg megoldást vagy magyarázatot. +4. Töltsd ki a típushoz tartozó helyes válaszmezőket. +5. Kapcsold a feladatot egy vagy több fogalomhoz. +6. Állítsd be a pedagógiai szerepet, nehézséget, várt időt és bizonyítékmélységet. +7. Ellenőrizd a tanulói előnézetet, majd ments piszkozatot vagy tedd közzé. + +## Típusspecifikus ellenőrzőlista + +- [ ] Egyszeres választásnál pontosan egy helyes lehetőség van. +- [ ] Többszörös választásnál legalább egy helyes lehetőség van. +- [ ] A hibás lehetőségek hihető tévedéseket képviselnek, nem trükkök. +- [ ] Számértéknél a tolerancia és a mértékegység szándékos. +- [ ] Elfogadott szövegnél minden gyakori helyes alak szerepel. +- [ ] Kifejtős válasznál az értékelési szempont segíti a következetes javítást. + +### Tolerancia példa + +Ha a várt érték `6,61`, az abszolút tolerancia pedig `0,02`, akkor a `6,59` és `6,63` közötti számok elfogadhatók. A tolerancia nem százalék. + +### Normalizálás + +A szóköz- és kis/nagybetű-tűrő mód a válasz elejéről/végéről eltávolítja a szóközt és nem különbözteti meg a betűméretet. A pontos mód minden karaktert figyelembe vesz. diff --git a/apps/web/src/manual/hu/create-materials.md b/apps/web/src/manual/hu/create-materials.md new file mode 100644 index 0000000..6b0afba --- /dev/null +++ b/apps/web/src/manual/hu/create-materials.md @@ -0,0 +1,24 @@ +# Fogalom és tananyag létrehozása + +## Új fogalom + +1. Nyisd meg a **Könyvtárat**, majd válaszd az **Új elem** műveletet. +2. Válaszd a **Fogalom** típust. +3. Adj egy rövid, egyértelmű címet és összefoglalót. +4. A tartalomszerkesztőben használhatsz címsorokat, listákat, táblázatot és képleteket, például `$a^2+b^2=c^2$`. +5. Ellenőrizd az előnézetet, ments piszkozatot, majd tedd közzé. + +## Tananyagforrás + +Belső Markdown tananyagnál a teljes tartalom a Fonatban marad. Külső hivatkozásnál adj tanulók számára is érthető leírást, és ellenőrizd, hogy a céloldal biztonságosan megnyitható. + +## Kapcsolat hozzáadása + +1. Nyisd meg a könyvtári elem részleteit. +2. Válaszd ki a kapcsolat típusát. +3. A célmezőben név alapján válassz másik elemet. +4. Mentsd a kapcsolatot. + +Ne hozz létre önmagára mutató vagy azonos, megismételt kapcsolatot. A kapcsolat jelentését a [fogalomtár](/guide/terminology#kapcsolatok) ismerteti. + +> Tipp: archiválj a törlés helyett. Így a korábbi órák és kézbesítések érthetők maradnak. diff --git a/apps/web/src/manual/hu/getting-started.md b/apps/web/src/manual/hu/getting-started.md new file mode 100644 index 0000000..edd7bcc --- /dev/null +++ b/apps/web/src/manual/hu/getting-started.md @@ -0,0 +1,37 @@ +# Első lépések a Fonatban + +A Fonat a tanári munkát egy összefüggő folyamatként kezeli: **beállítás → tartalom → gyakorlás → óraterv → tanítás → visszajelzés → következő lépés**. + +## Első munkatér létrehozása + +1. Nyisd meg a **Ma** oldalt, és válaszd a **Beállítás befejezése** műveletet. +2. Add meg a tantárgyat, a tanulócsoportot és a tanévet. +3. Írd be a tanulók neveit, soronként egyet. Mentés előtt ellenőrizd a listát. +4. Add meg a helyszínt és a terem rövid jelét. +5. Nevezd el a kurzust úgy, hogy később is könnyű legyen felismerni, például „8. a matematika”. +6. Válaszd a **Kurzus létrehozása** gombot. + +Az adatok egy lépésben jönnek létre. Ha valamelyik kötelező mező hibás, a Fonat nem hagy félkész kurzust maga után. + +## Az első tanítási egység + +- Hozz létre egy [fogalmat vagy tananyagot](/guide/create-materials). +- Készíts egy [gyakorlófeladatot](/guide/create-exercises). +- Állíts össze egy [óratervet](/guide/build-a-lesson). +- Nézd meg a tanulói előnézetet, majd tedd közzé. + +> Tipp: először mindig piszkozatként ments. Közzététel előtt ellenőrizd a tanulói nézetet és a diagnosztikát. + +## Hol találod a napi munkát? + +| Terület | Mire való? | +| ----------------- | --------------------------------------------------- | +| Ma | Következő órák, folyamatban lévő munkák és jelzések | +| Órarend | Kurzusok, időpontok, helyszínek és átfedések | +| Óratervek | Tanóra összeállítása és bemutatása | +| Kiosztások | Otthoni vagy órai gyakorlás és javítás | +| Felmérések | Felmérési sablon, stabil A/B változat és értékelés | +| Könyvtár | Fogalmak és tananyagforrások | +| Gyakorlófeladatok | Újrahasznosítható feladatok | + +Ha egy kifejezés nem világos, keresd meg a [fogalomtárban](/guide/terminology). diff --git a/apps/web/src/manual/hu/live-session.md b/apps/web/src/manual/hu/live-session.md new file mode 100644 index 0000000..78f5734 --- /dev/null +++ b/apps/web/src/manual/hu/live-session.md @@ -0,0 +1,17 @@ +# Élő kérdés biztonságos használata + +1. Az óratervben adj hozzá **Élő kérdés** tevékenységet és válassz közzétett feladatot. +2. Indítsd el a bemutatást. +3. Oszd meg a rövid csatlakozási kódot vagy QR-kódot. +4. Várd meg a válasz beérkezését; a tanuló elfogadási visszajelzést kap. +5. Az eredményt csak akkor fedd fel, amikor a csoport készen áll a megbeszélésre. + +## Adatvédelmi tippek + +- Használj álnevet vagy generált jelvényt nyilvános kivetítésen. +- Ne vetíts egyéni hibás válaszokat a szabályzat által engedett helyzet előtt. +- A rangsor ne jutalmazza alapértelmezetten a gyorsaságot. + +## Kapcsolati hiba + +Ha egy válasz után nincs visszaigazolás, a tanuló ne küldje el gyors egymásutánban többször. A Fonat azonos résztvevő és alkalom esetén idempotensen kezeli az elfogadott választ, majd a következő frissítéskor megjeleníti az állapotot. diff --git a/apps/web/src/manual/hu/revisions-and-history.md b/apps/web/src/manual/hu/revisions-and-history.md new file mode 100644 index 0000000..9570ba9 --- /dev/null +++ b/apps/web/src/manual/hu/revisions-and-history.md @@ -0,0 +1,28 @@ +# Revíziók és megőrzött előzmények + +## Mi változhat? + +- A piszkozat mentése ugyanazt a munkaváltozatot frissíti. +- A fő szerkesztők elhagyás előtt jelzik a még nem mentett helyi módosításokat; ez nem revízió, amíg nem választod a mentést. +- A közzététel számozott revíziót készíthet. +- Egy még nem kézbesített terv explicit módon frissíthető új revízióra. + +## Mi nem változhat visszamenőleg? + +- befejezett órafuttatás; +- beadott próbálkozás; +- létrehozott felmérési kézbesítés; +- rögzített hivatalos döntés előzménye. + +Ezért láthatsz régebbi megfogalmazást egy korábbi beadásnál akkor is, ha az eredeti gyakorlófeladatot már átírtad. + +## Egyidejű szerkesztés + +Ha a Fonat jelzi, hogy más módosította az elemet: + +1. másold ki a még nem mentett fontos szöveget; +2. töltsd újra az oldalt; +3. vesd össze a változásokat; +4. alkalmazd újra a szükséges módosítást. + +Ne próbáld ismételten felülírni az ütközést. diff --git a/apps/web/src/manual/hu/teacher-tips.md b/apps/web/src/manual/hu/teacher-tips.md new file mode 100644 index 0000000..09b9fc7 --- /dev/null +++ b/apps/web/src/manual/hu/teacher-tips.md @@ -0,0 +1,31 @@ +# Tanári tippek + +## Tartalom + +- Fogalmazz megfigyelhető tanulói tevékenységet: „indokold két lépésben” pontosabb, mint „értsd meg”. +- A megoldás ne csak a végeredményt, hanem az ellenőrzés módját is mutassa. +- Kapcsold a feladatokat fogalmakhoz; ettől lesz erősebb a keresés, a tervezés és a felmérési kiválasztás. +- Helyzetfüggő változathoz készíts másolatot/forkot az eredeti átírása helyett. + +## Óra + +- Váltogasd a magyarázatot, egyéni gondolkodást, páros beszélgetést és élő ellenőrzést. +- Hagyj időt a felfedés előtti érvelésre. +- A tanári jegyzetbe kerüljön a várható tévedés és a következő kérdés; a kivetített tartalom maradjon tömör. +- A lezárás nevezze meg a következő lépést vagy egy rövid ellenőrző feladatot. + +## Javítás és értékelés + +- Visszaküldéskor jelölj ki egy konkrét javítási célt. +- Nézd meg a próbálkozások közötti változást, ne csak az utolsó eredményt. +- A kézi felülbírálás indoka legyen ellenőrizhető és a tanulói teljesítményhez kapcsolódó. +- A megállapítás tanári döntést támogató jelzés, nem automatikus ítélet. + +## Biztonságos munkavégzés + +- Figyeld a szerkesztő mentési állapotát; navigálás előtt várd meg a **Minden változtatás mentve** jelzést vagy a mentett elem megnyitását. +- Kapcsolatvesztéskor hagyd nyitva a lapot, majd a felső figyelmeztetés eltűnése után ismételd meg kézzel a mentést. Így te döntöd el, melyik változat kerüljön a szerverre. +- Közzététel előtt nézd meg a tanulói előnézetet. +- Archiválj a történeti tartalom törlése helyett. +- Élő eredményt csak pedagógiailag megfelelő pillanatban fedj fel. +- Kilépéshez használd a Fonat látható Kilépés műveletét; ez érvényteleníti a szerveroldali munkamenetet. diff --git a/apps/web/src/manual/hu/terminology.md b/apps/web/src/manual/hu/terminology.md new file mode 100644 index 0000000..da62dcf --- /dev/null +++ b/apps/web/src/manual/hu/terminology.md @@ -0,0 +1,155 @@ +# Fonat fogalomtár + +Ez az oldal a tanári felületen és a csomagformátumban használt kifejezéseket egységesen magyarázza. Az angol név ott szerepel, ahol külső dokumentációban vagy tartalomcsomagban találkozhatsz vele. + +## Munkatér és hozzáférés + +| Magyar kifejezés | Angol/domain név | Jelentés | +| ---------------- | ---------------- | ----------------------------------------------------------------------------------- | +| Munkatér | Workspace | Egy telepítés tanári adatai, tartalmai, tervei és eredményei. | +| Admin | Admin | Fiókokat, funkciókapcsolókat és munkatér-visszaállítást kezelő rögzített szerep. | +| Tanári fiók | Teacher account | Bejelentkezett felhasználó, aki tartalmat tervez és tanulói munkát értékel. | +| Szerep | Role | Rögzített jogosultsági csoport, például admin vagy tanár. Nem egyedi engedélylista. | +| Munkamenet | Session | A szerveren nyilvántartott bejelentkezés. A Kilépés érvényteleníti. | + +## Szervezet és órarend + +| Kifejezés | Angol/domain név | Jelentés és különbség | +| ---------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------- | +| Tantárgy | Subject | A tanítás tág területe, például matematika. | +| Kurzus | Course | Egy konkrét tanítási keret: tantárgy, csoportok, tanulók, helyszín és időzóna kapcsolata. | +| Tanulói profil | Learner Profile | A tanuló megjelenített neve/álneve, jelvénye és tanári kontextusa. | +| Tanulócsoport | Learner Group | Tanévre vagy más szervezési egységre szóló csoport, például 8. a. | +| Beiratkozás | Enrollment | Időben érvényes kapcsolat egy tanuló és egy csoport között. | +| Névsor | Roster | Egy csoport vagy kurzus adott időpontra feloldott tanulói köre. | +| Kifejezett hozzáadás/kizárás | Explicit inclusion/exclusion | A csoport névsorától eltérő, kurzusszintű kivétel. Teljes szerkesztése még tervezett. | +| Tanítási helyszín | Teaching Location | Terem vagy más hely, ahol a kurzus/óra zajlik. | +| Órarendi bejegyzés | Timetable entry | Kurzus, hely és idő ismétlődő vagy konkrét kapcsolata. | +| Felülírás | Override | Az alapértéktől eltérő beállítás egy konkrét órára vagy döntésre. | + +## Könyvtár és tudáskapcsolatok + +| Kifejezés | Angol/domain név | Jelentés | +| -------------- | --------------------- | -------------------------------------------------------------------------------------------------------- | +| Könyvtár | Library | A kereshető tanári tartalomgyűjtemény. | +| Csomópont | Node | Közös technikai boríték a fogalmakhoz és tananyagforrásokhoz. | +| Fogalom | Concept | Tanulási tartalom vagy képesség, amelyhez definíció, feladat és bizonyíték kapcsolható. | +| Tananyagforrás | Resource | Markdown tananyag, külső hivatkozás vagy támogatott vizuális forrás. | +| Gyűjtemény | Collection | Tartalmak kézi csoportosítása. A teljes szerkesztő még tervezett. | +| Tulajdonos | Owner | Jelzi, hogy egy tartalom csomaghoz vagy tanárhoz tartozik. | +| Csomagtartalom | Package-owned content | Importált, eredeti formájában védett tartalom. Módosításhoz tanári másolat szükséges. | +| Tanári másolat | Teacher fork | Csomagtartalomból létrehozott, önállóan módosítható változat. A teljes fork-munkafolyamat még tervezett. | + +### Kapcsolatok + +A **Kapcsolat (Relation)** két könyvtári elem közötti, típussal ellátott összeköttetés. + +- **előfeltétele (requires):** a forráselem megértéséhez szükséges a célelem; +- **lefed (covers):** egy forrás érdemben foglalkozik a célfogalommal; +- **alternatívája (alternative-to):** hasonló tanulási célt más módon szolgál; +- **kiterjeszti (extends):** a forráselem továbbépíti a célelemet. + +## Tartalomállapot és előzmények + +| Kifejezés | Jelentés | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Piszkozat (draft) | Módosítható, tanári munkaállapot; még nem használható minden kézbesítésben. | +| Közzétett (published) | Ellenőrzött, kiválasztható tartalom. | +| Archivált (archived) | Új munkához nem ajánlott, de történeti hivatkozások miatt megőrzött tartalom. | +| Revízió (revision) | Közzétett tartalom számozott állapota. A teljes revíziókezelés még részben tervezett. | +| Rögzített revízió (pinned revision) | Egy terv által szándékosan választott pontos tartalomváltozat. | +| Megváltoztathatatlan pillanatkép (immutable snapshot) | A kézbesítéskor lemásolt tartalom, amelyet későbbi szerkesztés nem ír át. | +| Egyidejűségi ütközés (optimistic conflict) | Más mentett az általad megnyitott változat óta; újratöltés és összevetés szükséges. | + +## Gyakorlófeladatok + +A **Gyakorlófeladat (Exercise)** újrahasznosítható feladat, nem azonos a **Kiosztással (Assignment)**. A kiosztás egy vagy több gyakorlófeladatot ad egy kurzusnak. + +| Típus | Mire való? | +| --------------------------------------- | -------------------------------------------------- | +| Kifejtős / magyarázat (manual response) | Szabad válasz, tanári értékeléssel. | +| Egyszeres választás (single choice) | Legalább két lehetőség, pontosan egy helyes. | +| Többszörös választás (multiple choice) | Legalább két lehetőség, egy vagy több helyes. | +| Igaz / hamis (boolean) | Egy állítás kanonikus igazságértékkel. | +| Számérték (numeric) | Várt szám, tolerancia és opcionális mértékegység. | +| Elfogadott szöveg (accepted text) | Rövid válasz egy vagy több elfogadott változattal. | + +- **Feladatszöveg (prompt):** amit a tanuló lát és megválaszol. +- **Megoldás/magyarázat (solution):** helyes megoldás vagy tanári magyarázat. +- **Válaszlehetőség (option):** stabil azonosítójú válasz egy választós feladatban. +- **Elfogadott válasz:** automatikusan helyesnek tekintett érték vagy szöveg. +- **Tolerancia:** a várt szám körül elfogadott abszolút eltérés. +- **Normalizálás:** pontos vagy szóköz-/kisbetű-tűrő szöveg-összehasonlítás. +- **Nehézség:** 1–5 közötti tanári becslés. +- **Várt idő:** a megoldás tervezett percei. +- **Értékelési szempont (rubric):** kifejtős válasz kézi értékelését segítő leírás. +- **Bizonyítékpolitika:** mennyi tanulási bizonyítékot őrizzen a munkafolyamat. + +## Tervezés és tanítás + +| Kifejezés | Jelentés | +| ---------------------------------- | --------------------------------------------------------------------------- | +| Éves terv (Annual Plan) | Kurzus tanévi tanulási szakaszai és lefedettsége. | +| Tanulási szakasz (Phase) | Időben rendezett tervrész, fogalmakkal és órákkal. | +| Óraterv (Lesson) | Konkrét, sorrendbe állított tanári és tanulói tevékenységek. | +| Óravázlat (Lesson Blueprint) | Új órák alapértelmezett helyei és elvárásai; teljes szerkesztése tervezett. | +| Elrendezés (Lesson Layout) | Megjelenési/nyomtatási szerkezet; teljes szerkesztése tervezett. | +| Tevékenység/dia (Activity/slide) | Az óra egy szerkeszthető lépése. | +| Diagnosztika | Közzétételt blokkoló hiba vagy javítást javasló jelzés. | +| Előfeltétel | Olyan fogalom, amelyre egy másik tartalom épít. | +| Tanítási profil (Teaching Profile) | Pedagógiai alapbeállítások rétege; teljes szerkesztése tervezett. | + +## Bemutatás és élő óra + +- **Bemutató mód (Presentation Mode):** tanári vezérlő az óra lejátszásához. +- **Kivetített nézet (projected view):** csak tanulóbiztos tartalom; nem mutat tanári jegyzetet vagy rejtett megoldást. +- **Órafuttatás (Lesson Run):** egy óraterv konkrét aktív, szüneteltetett vagy befejezett alkalma. +- **Szünet és kilépés:** megőrzi a folytatható állapotot. +- **Folytatás:** visszatér a szüneteltetett futtatáshoz. +- **Befejezés:** lezárja az alkalmat; nem azonos a kilépéssel. +- **Élő munkamenet (live session):** rövid kóddal elérhető tanulói válaszadás. +- **Csatlakozási kód:** az adott élő alkalom rövid kódja. +- **Résztvevő:** az adott élő alkalomhoz tartozó vendég vagy tanuló. +- **Felfedés (reveal):** tanári döntés az eredmény/megoldás láthatóvá tételéről. +- **Válaszállapot:** beérkezett válaszok száma és állapota. +- **Rangsor (leaderboard):** adatvédelmi szempontból biztonságos, opcionális összesítés. + +## Kiosztás, beadás és bizonyíték + +| Kifejezés | Jelentés | +| --------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Kiosztás (Assignment) | Gyakorlófeladatok célzott, határidős kiadása. | +| Tanulói piszkozat | Szerveren mentett, még be nem adott válasz. | +| Beadás (Submission) | Egy próbálkozás megváltoztathatatlan pillanatképe. | +| Próbálkozás (attempt) | Beadás sorszámozott változata. | +| Visszaküldött (returned) | Javítást kérő tanári döntés. | +| Újrabeadott (resubmitted) | Visszaküldés után létrejött új próbálkozás. | +| Elfogadott (accepted) | Tanár által lezárt próbálkozás. | +| Visszajelzés kiadása | Szabály arról, mikor látható a tanári/automatikus visszajelzés. | +| Tanulási bizonyíték (Learning Evidence) | Megoldásból, javításból vagy megfigyelésből származó, forrással ellátott jel. | +| Bizonyosság (confidence) | A tanuló saját magabiztossági jelzése. | +| Támaszhasználat (scaffold use) | Segítség vagy lépésenkénti támasz használatának nyoma. | +| Megállapítás (Finding) | Bizonyítékból származó, magyarázott tanári jelzés; nem ír át automatikusan jegyet vagy tervet. | +| Jegybejegyzés (Grade Entry) | Hivatalos érték, forrással és rögzítési idővel. | + +## Felmérés + +| Kifejezés | Jelentés | +| --------------------------------------- | -------------------------------------------------------------------------------------- | +| Felmérési sablon (Assessment Blueprint) | Kurzushoz tartozó követelményhelyek és pontok. | +| Követelményhely (slot) | Egy fogalomhoz és pontértékhez illesztendő feladat helye. | +| Forrásszűrő | Meghatározza, milyen tartalomból választhat a generálás; bővített változata tervezett. | +| Hiány (shortfall) | Nincs a feltételnek megfelelő közzétett feladat; a Fonat nem lazít csendben. | +| A/B változat | Egyenértékű követelményhelyekből determinisztikusan összeállított változat. | +| Kézbesítés (Delivery) | Egy tanuló stabil, megváltoztathatatlan felmérési példánya. | +| Automatikus értékelés | Támogatott feladattípusok szabályalapú ellenőrzése. | +| Kézi felülbírálás | Tanári százalék, kötelező indokkal. | +| Újraértékelés (regrade) | Értékelési szabály változásának előnézete és alkalmazása; teljes felülete tervezett. | + +## Csomagok és opcionális funkciók + +- **Tartalomcsomag (content package):** nem végrehajtható, sémával ellenőrzött oktatási tartalom. +- **Import/alkalmazás:** ellenőrzött csomag munkatérbe helyezése; teljes ZIP-folyamata tervezett. +- **Frissítés:** csomag új verziójának hatásvizsgálata és alkalmazása; részben tervezett. +- **Projekt (Project):** elkülönített, több kihívást összekapcsoló funkció. +- **Funkciókapcsoló (feature flag):** admin által kapcsolható képesség; kikapcsoláskor az adat megmarad. diff --git a/apps/web/src/manual/hu/troubleshooting.md b/apps/web/src/manual/hu/troubleshooting.md new file mode 100644 index 0000000..983410b --- /dev/null +++ b/apps/web/src/manual/hu/troubleshooting.md @@ -0,0 +1,48 @@ +# Hibaelhárítás + +## Nem enged közzétenni + +Olvasd el a diagnosztikai hibákat. Gyakori ok a hiányzó kurzus, üres tevékenység, élő kérdéshez nem választott gyakorlófeladat vagy érvénytelen időzítő. + +## Nem találom a tartalmat a választóban + +- Ellenőrizd, hogy a tartalom közzétett állapotú-e. +- Ellenőrizd a Könyvtár vagy Gyakorlófeladatok keresőjét. +- Archivált tartalom normál esetben nem választható új kézbesítéshez. + +## Ütközés mentéskor + +Más mentett az általad megnyitott változat óta. Töltsd újra és vesd össze a módosításokat. A már kézbesített tartalom nem íródik át. + +## Nincs hálózati kapcsolat + +- A felső figyelmeztető sáv jelzi, ha a böngésző biztosan kapcsolat nélkül van. A szerkesztőben lévő változtatások helyben megmaradnak, de a Fonat nem tekinti őket mentettnek. +- A mentési állapotnál a **Nem mentett módosítások** jelzés mutatja, hogy még van helyi munka. Kapcsolat nélkül a mentés nem indít felesleges kérést és egyértelmű hibát ad. +- Ne zárd be és ne töltsd újra a lapot. Várd meg, amíg eltűnik a kapcsolati sáv, majd nyomd meg újra a mentés gombját. A Fonat nem végez automatikus háttérmentést vagy ütközésveszélyes automatikus újraküldést. +- Ha a lap betöltésekor nem érhető el a szerver, az **Újracsatlakozás** képernyő jelenik meg. Hálózati hiba miatt a Fonat nem irányít át tévesen a belépéshez; csak valódi lejárt vagy tiltott munkamenet esetén kér új bejelentkezést. + +## Lejárt munkamenet + +Jelentkezz be újra. A szerveren mentett tanulói piszkozat megmarad. A fő tanári szerkesztők figyelmeztetnek, ha nem mentett módosítással navigálsz el vagy zárod be az oldalt; munkamenet-lejárat vagy böngészőhiba előtt azonban továbbra is érdemes rendszeresen piszkozatot menteni. + +## „Nem mentett módosítások” ablak + +- **Maradok és mentek:** bezárja a figyelmeztetést, és minden beírt adat a szerkesztőben marad. +- **Módosítások elvetése:** folytatja a kért navigációt, a helyi változtatások elvesznek. +- Az `Esc` billentyű a biztonságos „maradok” művelettel azonos. +- Sikeres mentés után a Fonat figyelmeztetés nélkül nyitja meg a mentett elemet. + +## Mit jelentenek a mentési állapotok? + +- **Nem mentett módosítások:** a böngészőben van olyan változtatás, amelyet még nem küldtél el. +- **Mentés folyamatban…:** a kérés már fut; várd meg a végét, a mentési műveletek addig nem indíthatók újra. +- **Minden változtatás mentve:** az utolsó mentés sikeresen befejeződött. +- A piros hibaüzenet mindig elsőbbséget élvez: javítsd a jelzett adatot vagy ismételd meg a mentést a kapcsolat helyreállása után. + +## Kikapcsolt funkció + +Az Admin oldalon egyes képességek, például a Projektek, kikapcsolhatók. Kikapcsoláskor az adat megmarad, de az útvonal nem használható. + +## Nem érkezik élő válasz + +Ellenőrizd, hogy az órafuttatás aktív-e, az aktuális tevékenységhez tartozik-e gyakorlófeladat, és a tanuló elfogadási visszajelzést kapott-e. diff --git a/apps/web/src/manual/index.ts b/apps/web/src/manual/index.ts new file mode 100644 index 0000000..c13b9c6 --- /dev/null +++ b/apps/web/src/manual/index.ts @@ -0,0 +1,142 @@ +import gettingStarted from "./hu/getting-started.md?raw"; +import terminology from "./hu/terminology.md?raw"; +import createMaterials from "./hu/create-materials.md?raw"; +import createExercises from "./hu/create-exercises.md?raw"; +import buildALesson from "./hu/build-a-lesson.md?raw"; +import liveSession from "./hu/live-session.md?raw"; +import assignAndReview from "./hu/assign-and-review.md?raw"; +import createAnAssessment from "./hu/create-an-assessment.md?raw"; +import revisionsAndHistory from "./hu/revisions-and-history.md?raw"; +import troubleshooting from "./hu/troubleshooting.md?raw"; +import teacherTips from "./hu/teacher-tips.md?raw"; + +export type ManualCategory = + "Első lépések" | "Fogalmak" | "Útmutatók" | "Hibaelhárítás" | "Tippek"; + +export type ManualArticle = { + slug: string; + title: string; + summary: string; + category: ManualCategory; + keywords: string[]; + body: string; + related: string[]; +}; + +export const manualArticles: ManualArticle[] = [ + { + slug: "getting-started", + title: "Első lépések", + summary: "Az első kurzus és tanítási egység létrehozása.", + category: "Első lépések", + keywords: ["beállítás", "kurzus", "csoport", "első óra"], + body: gettingStarted, + related: ["terminology", "create-materials", "build-a-lesson"], + }, + { + slug: "terminology", + title: "Fonat fogalomtár", + summary: "A teljes tanári és domain szókészlet példákkal.", + category: "Fogalmak", + keywords: ["fogalom", "szótár", "terminológia", "jelentés"], + body: terminology, + related: ["getting-started", "revisions-and-history"], + }, + { + slug: "create-materials", + title: "Fogalom és tananyag létrehozása", + summary: "Markdown tartalom, előnézet és kapcsolatok.", + category: "Útmutatók", + keywords: ["könyvtár", "fogalom", "tananyag", "kapcsolat"], + body: createMaterials, + related: ["create-exercises", "terminology"], + }, + { + slug: "create-exercises", + title: "Gyakorlófeladat készítése", + summary: "A hat támogatott feladattípus helyes beállítása.", + category: "Útmutatók", + keywords: ["gyakorlófeladat", "tolerancia", "választás", "rubrika"], + body: createExercises, + related: ["create-materials", "build-a-lesson", "assign-and-review"], + }, + { + slug: "build-a-lesson", + title: "Óraterv összeállítása", + summary: "Tevékenységek, diagnosztika és biztonságos bemutatás.", + category: "Útmutatók", + keywords: ["óraterv", "bemutatás", "dia", "tevékenység"], + body: buildALesson, + related: ["live-session", "create-exercises", "teacher-tips"], + }, + { + slug: "live-session", + title: "Élő kérdés használata", + summary: "Csatlakozás, válaszállapot, felfedés és adatvédelem.", + category: "Útmutatók", + keywords: ["élő", "kód", "válasz", "felfedés", "rangsor"], + body: liveSession, + related: ["build-a-lesson", "troubleshooting"], + }, + { + slug: "assign-and-review", + title: "Kiosztás és javítás", + summary: "Piszkozat, beadás, visszaküldés és elfogadás.", + category: "Útmutatók", + keywords: ["kiosztás", "beadás", "javítás", "visszajelzés"], + body: assignAndReview, + related: ["create-exercises", "revisions-and-history"], + }, + { + slug: "create-an-assessment", + title: "Felmérés készítése", + summary: "Követelményhelyek, hiány, A/B kézbesítés és értékelés.", + category: "Útmutatók", + keywords: ["felmérés", "sablon", "A/B", "kézbesítés", "értékelés"], + body: createAnAssessment, + related: ["create-exercises", "revisions-and-history"], + }, + { + slug: "revisions-and-history", + title: "Revíziók és előzmények", + summary: "Mi változhat és mi marad megváltoztathatatlan.", + category: "Fogalmak", + keywords: ["revízió", "pillanatkép", "ütközés", "előzmény"], + body: revisionsAndHistory, + related: ["terminology", "troubleshooting"], + }, + { + slug: "troubleshooting", + title: "Hibaelhárítás", + summary: "Kapcsolati, mentési, munkamenet- és élőóra-hibák.", + category: "Hibaelhárítás", + keywords: [ + "hiba", + "hálózat", + "offline", + "mentési állapot", + "ütközés", + "munkamenet", + "nem találom", + ], + body: troubleshooting, + related: ["getting-started", "revisions-and-history", "live-session"], + }, + { + slug: "teacher-tips", + title: "Tanári tippek", + summary: "Tömör pedagógiai és biztonságos használati tanácsok.", + category: "Tippek", + keywords: ["tipp", "módszertan", "javítás", "biztonság"], + body: teacherTips, + related: ["build-a-lesson", "assign-and-review", "create-an-assessment"], + }, +]; + +export const manualCategories: ManualCategory[] = [ + "Első lépések", + "Fogalmak", + "Útmutatók", + "Hibaelhárítás", + "Tippek", +]; diff --git a/apps/web/src/pages/AnnualPlanEditorPage.tsx b/apps/web/src/pages/AnnualPlanEditorPage.tsx new file mode 100644 index 0000000..c237d65 --- /dev/null +++ b/apps/web/src/pages/AnnualPlanEditorPage.tsx @@ -0,0 +1,306 @@ +import { useEffect, useState } from "react"; +import { arrayMove } from "@dnd-kit/sortable"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { ApiError, api, patch, post } from "../api"; +import { EditorSaveStatus } from "../components/ConnectionStatus"; +import { useUnsavedChanges } from "../components/UnsavedChangesGuard"; + +type NamedEntity = { id: string; title: string; type?: string }; +type Phase = { + id: string; + title: string; + startDate?: string; + endDate?: string; + conceptIds: string[]; +}; +type AnnualPlan = { + id: string; + title: string; + courseId: string; + schoolYear?: string; + status?: string; + phases?: Phase[]; + concurrencyVersion?: number; +}; + +export function AnnualPlanEditorPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const existing = useQuery({ + queryKey: ["annual-plan", id], + queryFn: () => api(`/api/annual-plans/${id}`), + enabled: Boolean(id), + }); + const legacyPhases = useQuery({ + queryKey: ["phases", id], + queryFn: () => + api>("/api/phases?limit=100"), + select: (items) => items.filter((phase) => phase.annualPlanId === id), + enabled: Boolean(id), + }); + const courses = useQuery({ + queryKey: ["courses", "annual-plan-selector"], + queryFn: () => api("/api/courses?limit=100"), + }); + const concepts = useQuery({ + queryKey: ["concepts", "annual-plan-selector"], + queryFn: () => api("/api/nodes?limit=100"), + select: (items) => items.filter((item) => item.type === "concept"), + }); + const [title, setTitle] = useState("Új éves terv"); + const [courseId, setCourseId] = useState(""); + const [schoolYear, setSchoolYear] = useState("2026/27"); + const [status, setStatus] = useState("draft"); + const [phases, setPhases] = useState([]); + const [dirty, setDirty] = useState(false); + const unsaved = useUnsavedChanges(dirty); + useEffect(() => { + if (!existing.data) return; + setTitle(existing.data.title); + setCourseId(existing.data.courseId || ""); + setSchoolYear(existing.data.schoolYear || "2026/27"); + setStatus(existing.data.status || "draft"); + setPhases( + existing.data.phases?.length + ? existing.data.phases + : legacyPhases.data || [], + ); + }, [existing.data, legacyPhases.data]); + useEffect(() => { + if (!id && !courseId && courses.data?.[0]) setCourseId(courses.data[0].id); + }, [id, courseId, courses.data]); + const updatePhase = (phaseId: string, changes: Partial) => { + setDirty(true); + setPhases((current) => + current.map((phase) => + phase.id === phaseId ? { ...phase, ...changes } : phase, + ), + ); + }; + const changePhases = (update: (current: Phase[]) => Phase[]) => { + setDirty(true); + setPhases(update); + }; + const save = useMutation({ + mutationFn: (nextStatus?: string) => { + const body = { + title, + courseId, + schoolYear, + status: nextStatus || status, + phases: phases.map((phase, order) => ({ ...phase, order: order + 1 })), + concurrencyVersion: existing.data?.concurrencyVersion, + }; + return id + ? patch(`/api/annual-plans/${id}`, body) + : post("/api/annual-plans", body); + }, + onSuccess: (plan) => { + setDirty(false); + unsaved.allowNavigation(); + navigate(`/annual-plans/${plan.id || id}`); + }, + }); + if (id && existing.isLoading) + return
Éves terv betöltése…
; + return ( + <> +
+
+ Éves tervezés +

{id ? title : "Új éves terv"}

+
+
+ + Mégse + + + +
+
+
setDirty(true)}> +
+ + + +
+ Lefedettség +

+ {new Set(phases.flatMap((phase) => phase.conceptIds)).size}{" "} + fogalom · {phases.length} tanulási szakasz +

+
+ + {save.error && ( +
+ {save.error instanceof ApiError && save.error.code === "CONFLICT" + ? "A tervet közben más módosította. Töltsd újra az oldalt." + : `A mentés sikertelen: ${save.error.message}`} +
+ )} +
+
+
+

Tanulási szakaszok

+ +
+ {phases.map((phase, index) => ( +
+
+ {index + 1}. szakasz +
+ + + +
+
+ +
+ + +
+ +
+ ))} + {!phases.length && ( +

Adj hozzá legalább egy tanulási szakaszt.

+ )} +
+
+ {unsaved.confirmation} + + ); +} diff --git a/apps/web/src/pages/AssessmentsPage.tsx b/apps/web/src/pages/AssessmentsPage.tsx index ae4c3d2..f459064 100644 --- a/apps/web/src/pages/AssessmentsPage.tsx +++ b/apps/web/src/pages/AssessmentsPage.tsx @@ -1,85 +1,512 @@ +import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "react-router-dom"; -import { api, post } from "../api"; +import { ApiError, api, patch, post } from "../api"; +import { EditorSaveStatus } from "../components/ConnectionStatus"; +import { useUnsavedChanges } from "../components/UnsavedChangesGuard"; + +type NamedEntity = { id: string; title: string; type?: string }; +type BlueprintSlot = { + id: string; + conceptId: string; + points: number; + difficultyMin?: number; + difficultyMax?: number; +}; +type Blueprint = NamedEntity & { + courseId?: string; + instructionsMarkdown?: string; + slots?: BlueprintSlot[]; + status?: string; + concurrencyVersion?: number; +}; +type Delivery = NamedEntity & { + learnerId: string; + variant: string; + status: string; + score?: { percent: number; automaticPercent?: number }; +}; +type Preview = { + totalPoints: number; + slots: Array< + BlueprintSlot & { + eligibleCount: number; + shortfall: boolean; + candidateTitles: string[]; + } + >; +}; + export function AssessmentsPage() { - const qc = useQueryClient(); + const queryClient = useQueryClient(); const blueprints = useQuery({ queryKey: ["blueprints"], - queryFn: () => api("/api/assessment-blueprints"), + queryFn: () => api("/api/assessment-blueprints?limit=100"), }); const deliveries = useQuery({ queryKey: ["deliveries"], - queryFn: () => api("/api/assessment-deliveries"), + queryFn: () => api("/api/assessment-deliveries?limit=100"), + }); + const courses = useQuery({ + queryKey: ["courses", "assessment-selector"], + queryFn: () => api("/api/courses?limit=100"), + }); + const concepts = useQuery({ + queryKey: ["concepts", "assessment-selector"], + queryFn: () => api("/api/nodes?limit=100"), + select: (items) => items.filter((item) => item.type === "concept"), + }); + const learners = useQuery({ + queryKey: ["learners", "assessment-selector"], + queryFn: () => api("/api/learners?limit=100"), + }); + const [editingId, setEditingId] = useState(); + const [title, setTitle] = useState("Új felmérési terv"); + const [courseId, setCourseId] = useState(""); + const [instructionsMarkdown, setInstructionsMarkdown] = useState(""); + const [slots, setSlots] = useState([]); + const [selectedBlueprintId, setSelectedBlueprintId] = useState(""); + const [learnerIds, setLearnerIds] = useState([]); + const [seed, setSeed] = useState("fonat-2026"); + const [preview, setPreview] = useState(); + const [manualPercent, setManualPercent] = useState>( + {}, + ); + const [overrideReason, setOverrideReason] = useState>( + {}, + ); + const [dirty, setDirty] = useState(false); + const unsaved = useUnsavedChanges(dirty); + useEffect(() => { + if (!courseId && courses.data?.[0]) setCourseId(courses.data[0].id); + if (!selectedBlueprintId && blueprints.data?.[0]) + setSelectedBlueprintId(blueprints.data[0].id); + }, [courseId, selectedBlueprintId, courses.data, blueprints.data]); + const edit = (blueprint: Blueprint) => { + setDirty(false); + setEditingId(blueprint.id); + setTitle(blueprint.title); + setCourseId(blueprint.courseId || ""); + setInstructionsMarkdown(blueprint.instructionsMarkdown || ""); + setSlots(blueprint.slots || []); + }; + const reset = () => { + setDirty(false); + setEditingId(undefined); + setTitle("Új felmérési terv"); + setInstructionsMarkdown(""); + setSlots([]); + }; + const saveBlueprint = useMutation({ + mutationFn: (status: string) => { + const body = { + title, + courseId, + instructionsMarkdown, + slots, + status, + concurrencyVersion: blueprints.data?.find( + (item) => item.id === editingId, + )?.concurrencyVersion, + }; + return editingId + ? patch(`/api/assessment-blueprints/${editingId}`, body) + : post("/api/assessment-blueprints", body); + }, + onSuccess: async (blueprint) => { + reset(); + setSelectedBlueprintId(blueprint.id); + await queryClient.invalidateQueries({ queryKey: ["blueprints"] }); + }, + }); + const previewGeneration = useMutation({ + mutationFn: () => + post("/api/assessments/preview", { + blueprintId: selectedBlueprintId, + }), + onSuccess: setPreview, }); const generate = useMutation({ - mutationFn: (id: string) => - post("/api/assessments/generate", { - blueprintId: id, - seed: "fonat-2026", - learnerIds: ["learner.red-panda", "learner.hedgehog"], + mutationFn: () => + post("/api/assessments/generate", { + blueprintId: selectedBlueprintId, + seed, + learnerIds, }), - onSuccess: () => qc.invalidateQueries({ queryKey: ["deliveries"] }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["deliveries"] }); + }, }); const grade = useMutation({ - mutationFn: (id: string) => - post(`/api/assessment-deliveries/${id}/grade`, {}), - onSuccess: () => qc.invalidateQueries({ queryKey: ["deliveries"] }), + mutationFn: (delivery: Delivery) => { + const value = manualPercent[delivery.id]; + return post(`/api/assessment-deliveries/${delivery.id}/grade`, { + ...(value === "" || value == null + ? {} + : { manualPercent: Number(value) }), + overrideReason: overrideReason[delivery.id], + }); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["deliveries"] }); + await queryClient.invalidateQueries({ queryKey: ["findings"] }); + await queryClient.invalidateQueries({ queryKey: ["grades"] }); + }, }); + const hasShortfall = preview?.slots.some((slot) => slot.shortfall); return ( <>
- Deterministic delivery + Stabil A/B kézbesítés

Felmérések

+
+ + Súgó + + +
-
-
-

Sablonok

- {blueprints.data?.map((b) => ( -
+
+
setDirty(true)} + onClickCapture={() => setDirty(true)} + > +
+

{editingId ? "Sablon szerkesztése" : "Új felmérési sablon"}

+ {editingId && ( + + )} +
+ + +