feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
.o_content_pool .cp-split-panel {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
min-height: 65vh;
|
||||
}
|
||||
.o_content_pool .cp-question-list {
|
||||
flex: 0 0 60%;
|
||||
min-width: 0;
|
||||
}
|
||||
.o_content_pool .cp-selected-panel {
|
||||
flex: 0 0 calc(40% - 1rem);
|
||||
min-width: 0;
|
||||
}
|
||||
.o_content_pool .cp-scroll {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.o_content_pool .cp-scroll-selected {
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.o_content_pool .cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_content_pool .table-hover tbody tr:hover {
|
||||
background-color: rgba(13, 110, 253, 0.04);
|
||||
}
|
||||
.o_content_pool .table-hover tbody tr.table-active {
|
||||
background-color: rgba(13, 110, 253, 0.08);
|
||||
}
|
||||
.o_content_pool .form-check-input {
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_content_pool .card {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Layout } from "@web/search/layout";
|
||||
|
||||
class ContentPoolBrowser extends Component {
|
||||
static template = "encoach_exam_template.ContentPool";
|
||||
static components = { Layout };
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
filters: { skill: "", difficulty: "", status: "active", search: "" },
|
||||
questions: [],
|
||||
selectedQuestions: [],
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
onWillStart(() => this.loadQuestions());
|
||||
}
|
||||
|
||||
async loadQuestions() {
|
||||
this.state.loading = true;
|
||||
const domain = this._buildDomain();
|
||||
try {
|
||||
const [questions, count] = await Promise.all([
|
||||
this.orm.searchRead(
|
||||
"encoach.question",
|
||||
domain,
|
||||
["skill", "question_type", "stem", "difficulty", "marks", "irt_b", "subject_id", "topic_id", "status"],
|
||||
{
|
||||
limit: this.state.pageSize,
|
||||
offset: (this.state.page - 1) * this.state.pageSize,
|
||||
order: "skill, difficulty",
|
||||
},
|
||||
),
|
||||
this.orm.searchCount("encoach.question", domain),
|
||||
]);
|
||||
this.state.questions = questions;
|
||||
this.state.totalCount = count;
|
||||
} catch (e) {
|
||||
console.error("Content pool load error:", e);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_buildDomain() {
|
||||
const domain = [];
|
||||
const f = this.state.filters;
|
||||
if (f.status) domain.push(["status", "=", f.status]);
|
||||
if (f.skill) domain.push(["skill", "=", f.skill]);
|
||||
if (f.difficulty) domain.push(["difficulty", "=", f.difficulty]);
|
||||
if (f.search) domain.push(["stem", "ilike", f.search]);
|
||||
return domain;
|
||||
}
|
||||
|
||||
onFilterChange(field, ev) {
|
||||
this.state.filters[field] = ev.target.value;
|
||||
this.state.page = 1;
|
||||
this.loadQuestions();
|
||||
}
|
||||
|
||||
onSearchInput(ev) {
|
||||
this.state.filters.search = ev.target.value;
|
||||
}
|
||||
|
||||
onSearchKeydown(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
this.state.page = 1;
|
||||
this.loadQuestions();
|
||||
}
|
||||
}
|
||||
|
||||
applySearch() {
|
||||
this.state.page = 1;
|
||||
this.loadQuestions();
|
||||
}
|
||||
|
||||
toggleQuestion(question) {
|
||||
const idx = this.state.selectedQuestions.findIndex(q => q.id === question.id);
|
||||
if (idx >= 0) {
|
||||
this.state.selectedQuestions.splice(idx, 1);
|
||||
} else {
|
||||
this.state.selectedQuestions.push({ ...question });
|
||||
}
|
||||
}
|
||||
|
||||
isSelected(questionId) {
|
||||
return this.state.selectedQuestions.some(q => q.id === questionId);
|
||||
}
|
||||
|
||||
removeSelected(questionId) {
|
||||
const idx = this.state.selectedQuestions.findIndex(q => q.id === questionId);
|
||||
if (idx >= 0) this.state.selectedQuestions.splice(idx, 1);
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.state.selectedQuestions = [];
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
if (this.state.page * this.state.pageSize < this.state.totalCount) {
|
||||
this.state.page++;
|
||||
this.loadQuestions();
|
||||
}
|
||||
}
|
||||
|
||||
prevPage() {
|
||||
if (this.state.page > 1) {
|
||||
this.state.page--;
|
||||
this.loadQuestions();
|
||||
}
|
||||
}
|
||||
|
||||
truncate(text, len) {
|
||||
if (!text) return "";
|
||||
return text.length > len ? text.substring(0, len) + "..." : text;
|
||||
}
|
||||
|
||||
skillLabel(val) {
|
||||
const map = { listening: "Listening", reading: "Reading", writing: "Writing", speaking: "Speaking", grammar: "Grammar", vocabulary: "Vocabulary" };
|
||||
return map[val] || val;
|
||||
}
|
||||
|
||||
difficultyClass(val) {
|
||||
return { easy: "bg-success", medium: "bg-warning text-dark", hard: "bg-danger" }[val] || "bg-secondary";
|
||||
}
|
||||
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.state.totalCount / this.state.pageSize));
|
||||
}
|
||||
|
||||
get selectedCount() {
|
||||
return this.state.selectedQuestions.length;
|
||||
}
|
||||
|
||||
get selectedMarks() {
|
||||
return this.state.selectedQuestions.reduce((s, q) => s + (q.marks || 1), 0);
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("encoach_content_pool", ContentPoolBrowser);
|
||||
@@ -0,0 +1,207 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Layout } from "@web/search/layout";
|
||||
|
||||
class ExamValidationReport extends Component {
|
||||
static template = "encoach_exam_template.ExamValidation";
|
||||
static components = { Layout };
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
examId: null,
|
||||
exam: null,
|
||||
checks: [],
|
||||
overallValid: false,
|
||||
passCount: 0,
|
||||
failCount: 0,
|
||||
warnCount: 0,
|
||||
expandedSections: {},
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
const ctx = this.props.action?.context || {};
|
||||
this.state.examId = ctx.active_id || ctx.exam_id || null;
|
||||
if (this.state.examId) {
|
||||
await this.runValidation();
|
||||
} else {
|
||||
await this.loadExamList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadExamList() {
|
||||
try {
|
||||
this.state.examList = await this.orm.searchRead(
|
||||
"encoach.exam.template",
|
||||
[],
|
||||
["name", "code", "type", "total_time_min"],
|
||||
{ limit: 50, order: "name" },
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to load exam list:", e);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async selectExam(examId) {
|
||||
this.state.examId = examId;
|
||||
this.state.loading = true;
|
||||
await this.runValidation();
|
||||
}
|
||||
|
||||
async runValidation() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const exams = await this.orm.searchRead(
|
||||
"encoach.exam.template",
|
||||
[["id", "=", this.state.examId]],
|
||||
["name", "code", "type", "total_time_min", "pass_threshold", "structure", "subject_id"],
|
||||
);
|
||||
this.state.exam = exams.length ? exams[0] : null;
|
||||
|
||||
if (!this.state.exam) {
|
||||
this.state.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const questions = await this.orm.searchRead(
|
||||
"encoach.question",
|
||||
[],
|
||||
["skill", "difficulty", "marks", "status"],
|
||||
);
|
||||
|
||||
this.state.checks = this._runChecks(this.state.exam, questions);
|
||||
this.state.passCount = this.state.checks.filter(c => c.status === "pass").length;
|
||||
this.state.failCount = this.state.checks.filter(c => c.status === "fail").length;
|
||||
this.state.warnCount = this.state.checks.filter(c => c.status === "warn").length;
|
||||
this.state.overallValid = this.state.failCount === 0;
|
||||
} catch (e) {
|
||||
console.error("Validation error:", e);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_runChecks(exam, questions) {
|
||||
const checks = [];
|
||||
const active = questions.filter(q => q.status === "active");
|
||||
const skills = ["listening", "reading", "writing", "speaking", "grammar", "vocabulary"];
|
||||
|
||||
checks.push({
|
||||
id: "name",
|
||||
category: "Template",
|
||||
label: "Exam name is set",
|
||||
status: exam.name ? "pass" : "fail",
|
||||
detail: exam.name || "Missing exam name",
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "time",
|
||||
category: "Template",
|
||||
label: "Time limit is configured",
|
||||
status: exam.total_time_min > 0 ? "pass" : "fail",
|
||||
detail: exam.total_time_min ? `${exam.total_time_min} minutes` : "No time limit set",
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "threshold",
|
||||
category: "Template",
|
||||
label: "Pass threshold defined",
|
||||
status: exam.pass_threshold > 0 ? "pass" : "warn",
|
||||
detail: exam.pass_threshold ? `${exam.pass_threshold}%` : "No threshold — all scores will pass",
|
||||
});
|
||||
|
||||
const poolSize = active.length;
|
||||
checks.push({
|
||||
id: "pool_size",
|
||||
category: "Content Pool",
|
||||
label: "Active questions available (min 10)",
|
||||
status: poolSize >= 10 ? "pass" : poolSize >= 5 ? "warn" : "fail",
|
||||
detail: `${poolSize} active questions in the pool`,
|
||||
});
|
||||
|
||||
for (const skill of skills) {
|
||||
const count = active.filter(q => q.skill === skill).length;
|
||||
if (count > 0) {
|
||||
checks.push({
|
||||
id: `skill_${skill}`,
|
||||
category: "Skill Coverage",
|
||||
label: `${skill.charAt(0).toUpperCase() + skill.slice(1)} questions available`,
|
||||
status: count >= 3 ? "pass" : "warn",
|
||||
detail: `${count} active questions for ${skill}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const difficulties = ["easy", "medium", "hard"];
|
||||
for (const d of difficulties) {
|
||||
const count = active.filter(q => q.difficulty === d).length;
|
||||
checks.push({
|
||||
id: `diff_${d}`,
|
||||
category: "Difficulty Balance",
|
||||
label: `${d.charAt(0).toUpperCase() + d.slice(1)} questions present`,
|
||||
status: count > 0 ? "pass" : "warn",
|
||||
detail: `${count} ${d} questions`,
|
||||
});
|
||||
}
|
||||
|
||||
const totalMarks = active.reduce((s, q) => s + (q.marks || 1), 0);
|
||||
checks.push({
|
||||
id: "marks",
|
||||
category: "Marks",
|
||||
label: "Sufficient total marks in pool",
|
||||
status: totalMarks >= 20 ? "pass" : totalMarks >= 10 ? "warn" : "fail",
|
||||
detail: `${totalMarks} total marks available`,
|
||||
});
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
toggleSection(category) {
|
||||
this.state.expandedSections[category] = !this.state.expandedSections[category];
|
||||
}
|
||||
|
||||
isSectionExpanded(category) {
|
||||
return this.state.expandedSections[category] !== false;
|
||||
}
|
||||
|
||||
checksForCategory(category) {
|
||||
return this.state.checks.filter(c => c.category === category);
|
||||
}
|
||||
|
||||
get categories() {
|
||||
const seen = new Set();
|
||||
return this.state.checks.reduce((arr, c) => {
|
||||
if (!seen.has(c.category)) {
|
||||
seen.add(c.category);
|
||||
arr.push(c.category);
|
||||
}
|
||||
return arr;
|
||||
}, []);
|
||||
}
|
||||
|
||||
categoryStatus(category) {
|
||||
const items = this.checksForCategory(category);
|
||||
if (items.some(c => c.status === "fail")) return "fail";
|
||||
if (items.some(c => c.status === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.state.examId = null;
|
||||
this.state.exam = null;
|
||||
this.state.checks = [];
|
||||
this.state.loading = true;
|
||||
this.loadExamList();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("encoach_exam_validation", ExamValidationReport);
|
||||
@@ -0,0 +1,224 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="encoach_exam_template.ContentPool">
|
||||
<Layout display="{ controlPanel: {} }">
|
||||
<div class="o_content_pool">
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h2 class="mb-0 me-3">Content Pool</h2>
|
||||
<span class="badge bg-primary fs-6" t-if="state.totalCount">
|
||||
<t t-esc="state.totalCount"/> questions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">Skill</label>
|
||||
<select class="form-select form-select-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('skill', ev)">
|
||||
<option value="">All Skills</option>
|
||||
<option value="listening">Listening</option>
|
||||
<option value="reading">Reading</option>
|
||||
<option value="writing">Writing</option>
|
||||
<option value="speaking">Speaking</option>
|
||||
<option value="grammar">Grammar</option>
|
||||
<option value="vocabulary">Vocabulary</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold mb-1">Difficulty</label>
|
||||
<select class="form-select form-select-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('difficulty', ev)">
|
||||
<option value="">All</option>
|
||||
<option value="easy">Easy</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="hard">Hard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold mb-1">Status</label>
|
||||
<select class="form-select form-select-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('status', ev)">
|
||||
<option value="">All</option>
|
||||
<option value="active" selected="selected">Active</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="retired">Retired</option>
|
||||
<option value="flagged">Flagged</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-bold mb-1">Search</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control"
|
||||
placeholder="Search question text..."
|
||||
t-on-input="onSearchInput"
|
||||
t-on-keydown="onSearchKeydown"/>
|
||||
<button class="btn btn-outline-primary" t-on-click="applySearch">
|
||||
<i class="fa fa-search"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 text-end">
|
||||
<button class="btn btn-sm btn-outline-secondary" t-on-click="() => { this.state.filters = { skill: '', difficulty: '', status: 'active', search: '' }; this.state.page = 1; this.loadQuestions(); }">
|
||||
<i class="fa fa-refresh"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center py-5">
|
||||
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
|
||||
<p class="mt-2 text-muted">Loading questions...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<div class="cp-split-panel">
|
||||
<!-- Left: Question List -->
|
||||
<div class="cp-question-list">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Available Questions</h6>
|
||||
<small class="text-muted">
|
||||
Page <t t-esc="state.page"/> of <t t-esc="totalPages"/>
|
||||
</small>
|
||||
</div>
|
||||
<div class="card-body p-0 cp-scroll">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th style="width:40px"/>
|
||||
<th>Question</th>
|
||||
<th style="width:90px">Skill</th>
|
||||
<th style="width:80px">Difficulty</th>
|
||||
<th style="width:55px">Marks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.questions" t-as="q" t-key="q.id">
|
||||
<tr t-att-class="{ 'table-active': isSelected(q.id) }"
|
||||
class="cursor-pointer"
|
||||
t-on-click="() => this.toggleQuestion(q)">
|
||||
<td class="text-center">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
t-att-checked="isSelected(q.id)"
|
||||
t-on-click.stop="() => this.toggleQuestion(q)"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="truncate(q.stem, 80)"/>
|
||||
<br/>
|
||||
<small class="text-muted" t-esc="q.question_type"/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary bg-opacity-75" t-esc="skillLabel(q.skill)"/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" t-att-class="difficultyClass(q.difficulty)" t-esc="q.difficulty"/>
|
||||
</td>
|
||||
<td class="text-center fw-bold" t-esc="q.marks"/>
|
||||
</tr>
|
||||
</t>
|
||||
<t t-if="!state.questions.length">
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-4">
|
||||
<i class="fa fa-inbox fa-2x mb-2 d-block"/>
|
||||
No questions match your filters
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
|
||||
<small class="text-muted">
|
||||
Showing <t t-esc="Math.min((state.page - 1) * state.pageSize + 1, state.totalCount)"/>-<t t-esc="Math.min(state.page * state.pageSize, state.totalCount)"/> of <t t-esc="state.totalCount"/>
|
||||
</small>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1"
|
||||
t-att-disabled="state.page <= 1"
|
||||
t-on-click="prevPage">
|
||||
<i class="fa fa-chevron-left"/>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
t-att-disabled="state.page >= totalPages"
|
||||
t-on-click="nextPage">
|
||||
<i class="fa fa-chevron-right"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Selected Questions -->
|
||||
<div class="cp-selected-panel">
|
||||
<div class="card shadow-sm h-100 border-primary">
|
||||
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fa fa-check-square me-1"/>
|
||||
Selected (<t t-esc="selectedCount"/>)
|
||||
</h6>
|
||||
<button class="btn btn-sm btn-outline-light"
|
||||
t-on-click="clearSelection"
|
||||
t-if="selectedCount > 0">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<!-- Summary -->
|
||||
<div class="row g-2 mb-3" t-if="selectedCount > 0">
|
||||
<div class="col-6">
|
||||
<div class="bg-light rounded p-2 text-center">
|
||||
<div class="fs-4 fw-bold text-primary" t-esc="selectedCount"/>
|
||||
<small class="text-muted">Questions</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="bg-light rounded p-2 text-center">
|
||||
<div class="fs-4 fw-bold text-success" t-esc="selectedMarks"/>
|
||||
<small class="text-muted">Total Marks</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Items -->
|
||||
<div class="cp-scroll-selected">
|
||||
<t t-foreach="state.selectedQuestions" t-as="sq" t-key="sq.id">
|
||||
<div class="d-flex align-items-start border rounded p-2 mb-2 bg-white">
|
||||
<div class="flex-grow-1 me-2">
|
||||
<small class="d-block" t-esc="truncate(sq.stem, 60)"/>
|
||||
<span class="badge bg-primary bg-opacity-50 me-1" style="font-size:0.65rem" t-esc="skillLabel(sq.skill)"/>
|
||||
<span class="badge" t-att-class="difficultyClass(sq.difficulty)" style="font-size:0.65rem" t-esc="sq.difficulty"/>
|
||||
<span class="badge bg-dark bg-opacity-50" style="font-size:0.65rem">
|
||||
<t t-esc="sq.marks"/> mk
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger flex-shrink-0"
|
||||
t-on-click.stop="() => this.removeSelected(sq.id)"
|
||||
title="Remove">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="selectedCount === 0">
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fa fa-hand-pointer-o fa-2x mb-2 d-block"/>
|
||||
<small>Click questions to select them</small>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,132 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="encoach_exam_template.ExamValidation">
|
||||
<Layout display="{ controlPanel: {} }">
|
||||
<div class="o_exam_validation">
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center py-5">
|
||||
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
|
||||
<p class="mt-2 text-muted">Running validation...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Exam selector (when no exam_id in context) -->
|
||||
<t t-elif="!state.examId and state.examList">
|
||||
<h2 class="mb-3">Exam Validation Report</h2>
|
||||
<p class="text-muted mb-4">Select an exam template to validate.</p>
|
||||
<div class="row g-3">
|
||||
<t t-foreach="state.examList" t-as="ex" t-key="ex.id">
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card shadow-sm h-100 cursor-pointer ev-exam-card"
|
||||
t-on-click="() => this.selectExam(ex.id)">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title" t-esc="ex.name"/>
|
||||
<span class="badge bg-secondary me-1" t-if="ex.code" t-esc="ex.code"/>
|
||||
<span class="badge bg-info" t-esc="ex.type"/>
|
||||
<div class="mt-2 text-muted small" t-if="ex.total_time_min">
|
||||
<i class="fa fa-clock-o me-1"/>
|
||||
<t t-esc="ex.total_time_min"/> min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Validation Results -->
|
||||
<t t-elif="state.exam">
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
|
||||
t-if="!this.props.action?.context?.active_id">
|
||||
<i class="fa fa-arrow-left me-1"/> Back
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="mb-0">
|
||||
Validation: <t t-esc="state.exam.name"/>
|
||||
</h2>
|
||||
<small class="text-muted" t-if="state.exam.code">
|
||||
Code: <t t-esc="state.exam.code"/>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Status Banner -->
|
||||
<div class="alert d-flex align-items-center mb-4"
|
||||
t-att-class="{ 'alert-success': state.overallValid, 'alert-danger': !state.overallValid }">
|
||||
<i class="fa fa-3x me-3"
|
||||
t-att-class="{ 'fa-check-circle': state.overallValid, 'fa-exclamation-triangle': !state.overallValid }"/>
|
||||
<div>
|
||||
<h5 class="mb-1" t-if="state.overallValid">All Checks Passed</h5>
|
||||
<h5 class="mb-1" t-else="">Validation Issues Found</h5>
|
||||
<span>
|
||||
<span class="badge bg-success me-1"><t t-esc="state.passCount"/> passed</span>
|
||||
<span class="badge bg-danger me-1" t-if="state.failCount"><t t-esc="state.failCount"/> failed</span>
|
||||
<span class="badge bg-warning text-dark" t-if="state.warnCount"><t t-esc="state.warnCount"/> warnings</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Check Categories -->
|
||||
<t t-foreach="categories" t-as="cat" t-key="cat">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center cursor-pointer py-2"
|
||||
t-on-click="() => this.toggleSection(cat)">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fa me-2"
|
||||
t-att-class="{
|
||||
'fa-check-circle text-success': categoryStatus(cat) === 'pass',
|
||||
'fa-exclamation-circle text-danger': categoryStatus(cat) === 'fail',
|
||||
'fa-exclamation-triangle text-warning': categoryStatus(cat) === 'warn'
|
||||
}"/>
|
||||
<h6 class="mb-0" t-esc="cat"/>
|
||||
</div>
|
||||
<i class="fa"
|
||||
t-att-class="{ 'fa-chevron-down': isSectionExpanded(cat), 'fa-chevron-right': !isSectionExpanded(cat) }"/>
|
||||
</div>
|
||||
<div class="card-body p-0" t-if="isSectionExpanded(cat)">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
<t t-foreach="checksForCategory(cat)" t-as="check" t-key="check.id">
|
||||
<tr>
|
||||
<td style="width:40px" class="text-center">
|
||||
<i class="fa"
|
||||
t-att-class="{
|
||||
'fa-check text-success': check.status === 'pass',
|
||||
'fa-times text-danger': check.status === 'fail',
|
||||
'fa-exclamation text-warning': check.status === 'warn'
|
||||
}"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="check.label"/>
|
||||
</td>
|
||||
<td class="text-muted small text-end" t-esc="check.detail"/>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" t-on-click="() => this.runValidation()">
|
||||
<i class="fa fa-refresh me-1"/> Re-run Validation
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-elif="!state.examId">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fa fa-clipboard fa-3x mb-3 d-block"/>
|
||||
<p>No exam template found. Open this from an exam template record.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</t>
|
||||
</templates>
|
||||
Reference in New Issue
Block a user