feat: institutional + support + training admin sections (backend + frontend)

Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs.

Backend (new `encoach_lms_api` addon + existing addons):
- Institutional: academic years/terms, departments, admission registers & admissions,
  courses/batches, lessons, fees (terms + student fees + invoicing with income-account
  auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset),
  student leave, result templates + marksheets (incl. delete-with-cascade).
- Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived
  from `op.student.fees.details` and `account.move`; platform settings backed by
  `encoach.code` and `ir.config_parameter` (packages + grading config).
- Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models)
  with CRUD, pagination, search/level filters, and upsert-style progress endpoints.
  Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`;
  `ValidationError`/`UserError` mapped to HTTP 400.

Frontend:
- Rewire institutional admin pages (Academic Year Manager, Admissions, Courses,
  Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets,
  Taxonomy, Resources) to real APIs with React Query invalidation and dialogs.
- New typed services: `payments.service.ts`, `platformSettings.service.ts`,
  `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/
  resources/student-progress/generation` services + related types.
- Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`,
  `TicketsPage` to consume live data with search/filter/progress/CRUD flows.
- New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`.
- Favicons/branding assets and misc. UX polish across teacher/student pages.

Tooling & QA:
- Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent,
  covers institutional + support + training fixtures incl. income-account wiring).
- API write-flow test suites: `test_write_flows.py` (institutional),
  `test_support_flows.py` (support), `test_training_flows.py` (training),
  `test_ai_full.py`. All suites pass end-to-end.
- Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA.
- `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`,
  `frontend/node_modules/`.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 03:13:23 +04:00
parent b78124bb7b
commit 435930a827
71 changed files with 6677 additions and 1309 deletions

View File

@@ -436,12 +436,60 @@ export default function GenerationPage() {
const currentState = activeModule ? getModuleState(activeModule) : null;
/**
* Build the rich "persona context" that the backend AI controller uses to
* build its system prompt. Every parameter the user has configured in the
* UI (exam mode, structure, entity, rubric, grading system, access type,
* passage category/type, module, subject) flows through here so the
* generated questions actually reflect those choices.
*/
const buildPersonaContext = useCallback(
(mod: ModuleKey): Record<string, unknown> => {
const st = getModuleState(mod);
const entityIdNum = st.entity && st.entity !== "" && st.entity !== "none"
? Number(st.entity)
: undefined;
const entityObj = entityIdNum
? entities.find((e) => e.id === entityIdNum)
: undefined;
let rubricIdNum: number | undefined;
if (st.rubricId && st.rubricId.startsWith("rubric-")) {
const parsed = Number(st.rubricId.split("-")[1]);
if (!Number.isNaN(parsed)) rubricIdNum = parsed;
}
const rubricObj = rubricIdNum
? rubrics.find((r) => r.id === rubricIdNum)
: undefined;
const structureObj = selectedStructure;
return {
module: mod,
exam_mode: examMode,
exam_title: title || undefined,
exam_label: examLabel || undefined,
structure_name: structureObj?.name,
entity_id: entityIdNum,
entity_name: entityObj?.name,
rubric_id: rubricIdNum,
rubric_name: rubricObj?.name,
grading_system: st.gradingSystem || undefined,
access_type: st.accessType || undefined,
};
},
[getModuleState, entities, rubrics, selectedStructure, examMode, title, examLabel],
);
const generatePassageMut = useMutation({
mutationFn: (params: { index: number; topic: string; difficulty: string; wordCount: number }) =>
mutationFn: (params: { index: number; topic: string; difficulty: string; wordCount: number; category: string; passageType: string }) =>
generationService.generatePassage({
...buildPersonaContext(activeModule ?? "reading"),
topic: params.topic,
difficulty: params.difficulty,
word_count: params.wordCount,
category: params.category,
passage_type: params.passageType,
}),
onSuccess: (res, vars) => {
if (!activeModule) return;
@@ -463,19 +511,36 @@ export default function GenerationPage() {
types: string[];
typeCounts: Record<string, number>;
typeInstructions: Record<string, string>;
typeDifficulties?: Record<string, string>;
passageText: string;
difficulty: string;
}) => {
const mod = params.module === "level" || params.module === "industry" ? "reading" : params.module;
const totalCount = Object.values(params.typeCounts).reduce((s, n) => s + n, 0);
const st = getModuleState(params.module);
let category: string | undefined;
let passageType: string | undefined;
if (params.module === "listening") {
const sec = st.listeningSections[params.passageIndex];
category = sec?.category;
passageType = sec?.type;
} else {
const p = st.passages[params.passageIndex];
category = p?.category;
passageType = p?.type;
}
return generationService.generateExercises(mod as "reading" | "listening" | "writing" | "speaking", {
...buildPersonaContext(params.module),
passage_index: params.passageIndex,
exercise_types: params.types,
count_per_type: totalCount,
passage_text: params.passageText,
type_counts: params.typeCounts,
type_instructions: params.typeInstructions,
type_difficulties: params.typeDifficulties,
difficulty: params.difficulty,
category,
passage_type: passageType,
} as Record<string, unknown> & { passage_index: number; exercise_types: string[] });
},
onSuccess: (res, vars) => {
@@ -536,8 +601,18 @@ export default function GenerationPage() {
});
const generateWritingMut = useMutation({
mutationFn: (params: { topic: string; difficulty: string; taskIndex: number }) =>
generationService.generateWritingInstructions({ topic: params.topic, difficulty: params.difficulty, task_type: "letter" }),
mutationFn: (params: { topic: string; difficulty: string; taskIndex: number }) => {
const st = getModuleState("writing");
const task = st.writingTasks[params.taskIndex];
return generationService.generateWritingInstructions({
...buildPersonaContext("writing"),
topic: params.topic,
difficulty: params.difficulty,
task_type: task?.type || "essay",
category: task?.category || undefined,
word_limit: task?.wordLimit,
});
},
onSuccess: (res, vars) => {
const st = getModuleState("writing");
const tasks = [...st.writingTasks];
@@ -551,8 +626,17 @@ export default function GenerationPage() {
});
const generateSpeakingMut = useMutation({
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) =>
generationService.generateSpeakingScript({ topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: "speaking_1" }),
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) => {
const st = getModuleState("speaking");
const part = st.speakingParts[params.partIndex];
return generationService.generateSpeakingScript({
...buildPersonaContext("speaking"),
topics: params.topics.filter(Boolean),
difficulty: params.difficulty,
part: part?.type || "speaking_1",
category: part?.category || undefined,
});
},
onSuccess: (res, vars) => {
const r = res as Record<string, unknown>;
if (r.error) {
@@ -874,7 +958,14 @@ export default function GenerationPage() {
}} />
<Button size="sm" className="w-full text-xs" disabled={generatePassageMut.isPending}
onClick={() => {
generatePassageMut.mutate({ index: pi, topic: passage.category || "", difficulty: st.difficulty[0] || "B2", wordCount: Number(passage.divider) || 300 });
generatePassageMut.mutate({
index: pi,
topic: passage.category || "",
difficulty: st.difficulty[0] || "B2",
wordCount: Number(passage.divider) || 300,
category: passage.category || "",
passageType: passage.type || "academic",
});
}}>
{generatePassageMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
Generate
@@ -1126,7 +1217,13 @@ export default function GenerationPage() {
}} />
<div className="flex gap-2">
<Button size="sm" className="flex-1 text-xs" onClick={() => {
generationService.generateListeningContext({ topic: sec.category || "general conversation", section_type: sec.type })
generationService.generateListeningContext({
...buildPersonaContext("listening"),
topic: sec.category || "general conversation",
section_type: sec.type,
category: sec.category || undefined,
difficulty: st.difficulty[0] || "B1",
})
.then((res) => {
const r = res as Record<string, unknown>;
const sections = [...st.listeningSections];
@@ -2680,9 +2777,11 @@ export default function GenerationPage() {
const types = wizardEnabledTypes.map(([key]) => key);
const typeCounts: Record<string, number> = {};
const typeInstructions: Record<string, string> = {};
const typeDifficulties: Record<string, string> = {};
for (const [key, cfg] of wizardEnabledTypes) {
typeCounts[key] = cfg.count;
if (cfg.instructions.trim()) typeInstructions[key] = cfg.instructions.trim();
if (cfg.difficulty) typeDifficulties[key] = cfg.difficulty;
}
const difficulty = wizardEnabledTypes[0]?.[1]?.difficulty || st.difficulty[0] || "B2";
generateExercisesMut.mutate({
@@ -2691,6 +2790,7 @@ export default function GenerationPage() {
types,
typeCounts,
typeInstructions,
typeDifficulties,
passageText: text,
difficulty,
});