Compare commits
226 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fcf6a253d | ||
|
|
be8d985d15 | ||
|
|
974c90dddc | ||
|
|
4811d395d2 | ||
|
|
132423d577 | ||
|
|
10829e2f00 | ||
|
|
47b908c964 | ||
|
|
0f8e471d5d | ||
|
|
2537119250 | ||
|
|
977066d114 | ||
|
|
46e956dc74 | ||
|
|
7afdd8d44f | ||
|
|
6daf204b4f | ||
|
|
2f4a550a4a | ||
|
|
fe214f6b41 | ||
|
|
ca7de81888 | ||
|
|
17ce20355a | ||
|
|
34981b4765 | ||
|
|
21151a2e09 | ||
|
|
1abb7f5b8c | ||
|
|
05998549a4 | ||
|
|
96283a3629 | ||
|
|
2bfc7abe9c | ||
|
|
4f389eca8d | ||
|
|
1789479955 | ||
|
|
59316dbaf9 | ||
|
|
b726073a5b | ||
|
|
adf897c812 | ||
|
|
1fc4c2442c | ||
|
|
414643ee90 | ||
|
|
1a1cbd6ea1 | ||
|
|
9ae809a62f | ||
|
|
eb9b1c905d | ||
|
|
fe9a8f49c1 | ||
|
|
f912c8fce3 | ||
|
|
1d1ca43c35 | ||
|
|
bce45f44e4 | ||
|
|
07583fb563 | ||
|
|
775aa23992 | ||
|
|
05ed6b7e73 | ||
|
|
d602694ea7 | ||
|
|
18d71bc0d4 | ||
|
|
3fa68643ba | ||
|
|
8904525c36 | ||
|
|
3ce09a98f3 | ||
|
|
b833768e71 | ||
|
|
b9a6afd993 | ||
|
|
b5a81ea927 | ||
|
|
750e92cdde | ||
|
|
da45f4c011 | ||
|
|
544bb5c11c | ||
|
|
1fc6f62f70 | ||
|
|
8751ad27ec | ||
|
|
159d3d5b87 | ||
|
|
34d6d99d8c | ||
|
|
6c46931b1a | ||
|
|
2c3e2d9d08 | ||
|
|
7be1562fa4 | ||
|
|
294389e7c7 | ||
|
|
2c8ce133f7 | ||
|
|
4f1d4d90d0 | ||
|
|
7b7484332b | ||
|
|
50e94b85aa | ||
|
|
9b820594ef | ||
|
|
ddcd45d56d | ||
|
|
c4a4c16516 | ||
|
|
5ae9ad0762 | ||
|
|
405f7d498e | ||
|
|
bcd6a5b1e7 | ||
|
|
e5e5ac994c | ||
|
|
e1f8d6ec49 | ||
|
|
6f50242f5a | ||
|
|
036f7ece05 | ||
|
|
622a2ff072 | ||
|
|
60334ca04a | ||
|
|
ade47b4e83 | ||
|
|
d7e550dfea | ||
|
|
c3cc0b9bf7 | ||
|
|
5ad89189c1 | ||
|
|
f1bbd4eb13 | ||
|
|
fba89dfacb | ||
|
|
b93ed41215 | ||
|
|
13ff6a7304 | ||
|
|
ad97405e55 | ||
|
|
376e231d7b | ||
|
|
e16d76f6dd | ||
|
|
ffd0fd92fc | ||
|
|
933613d730 | ||
|
|
9b0673bf92 | ||
|
|
7cba22aa28 | ||
|
|
af05b614a9 | ||
|
|
c0fa219a8b | ||
|
|
4e3a47b0f4 | ||
|
|
161276b58a | ||
|
|
47713019a5 | ||
|
|
010632a21d | ||
|
|
e77fe550af | ||
|
|
0a4233da14 | ||
|
|
56fb70ab1e | ||
|
|
4a1f2bc01d | ||
|
|
20292fbf16 | ||
|
|
1290cf8991 | ||
|
|
b8b8af7cf1 | ||
|
|
75f4f452d3 | ||
|
|
9de492384f | ||
|
|
14c4e161f2 | ||
|
|
c55efbc0ba | ||
|
|
f0610222d9 | ||
|
|
302ee4a50f | ||
|
|
2170819159 | ||
|
|
0d1fac321a | ||
|
|
dbbc1756dd | ||
|
|
d5b882d3f8 | ||
|
|
3025ea9a7b | ||
|
|
5dba4d1384 | ||
|
|
e4f1e7b093 | ||
|
|
d0a0597087 | ||
|
|
c9ccf9a1b5 | ||
|
|
69107d4441 | ||
|
|
e25afc1ef7 | ||
|
|
9babfd150e | ||
|
|
532dbbea4a | ||
|
|
0d284d05d9 | ||
|
|
28fccae3ac | ||
|
|
3a4a6da69c | ||
|
|
4ea07a95e7 | ||
|
|
80ceb49358 | ||
|
|
589337116a | ||
|
|
cb50067223 | ||
|
|
4d63266d88 | ||
|
|
90dd33ce21 | ||
|
|
763b849ddf | ||
|
|
9c76c54283 | ||
|
|
5cb17b3a36 | ||
|
|
2f7b5d1cbb | ||
|
|
4fe14eb2e9 | ||
|
|
eb089f2b58 | ||
|
|
4f0ac98eea | ||
|
|
af19940fa1 | ||
|
|
5635d2a325 | ||
|
|
5e2de35693 | ||
|
|
ef7180f23f | ||
|
|
f939973d4f | ||
|
|
63f327733e | ||
|
|
c1fb807fe4 | ||
|
|
b7ddf44267 | ||
|
|
6d4c72ea5e | ||
|
|
3db11b9372 | ||
|
|
b8714f4abe | ||
|
|
7ccbe74bbe | ||
|
|
ea3ae3516b | ||
|
|
d33af3ca52 | ||
|
|
291c3fa908 | ||
|
|
a51fa58122 | ||
|
|
65a3967abd | ||
|
|
e1e5c94a43 | ||
|
|
f15127eceb | ||
|
|
071a238b71 | ||
|
|
050b052156 | ||
|
|
8f65cca776 | ||
|
|
66624a8c47 | ||
|
|
c8b9a415e6 | ||
|
|
a1dcb4c203 | ||
|
|
d4edc3e622 | ||
|
|
e2b8c3ee0e | ||
|
|
c37816e90d | ||
|
|
a35cfcdca7 | ||
|
|
d381646226 | ||
|
|
285e7afec2 | ||
|
|
df7d678c32 | ||
|
|
f36f7e58de | ||
|
|
0e16c834d8 | ||
|
|
31a3256128 | ||
|
|
aa8f70da28 | ||
|
|
de240e40a5 | ||
|
|
3bbdc828d9 | ||
|
|
0d41a1ae70 | ||
|
|
b2b92aea31 | ||
|
|
e0680d9612 | ||
|
|
d286df649e | ||
|
|
e0cbc247b2 | ||
|
|
a2c8a82559 | ||
|
|
8b91323705 | ||
|
|
89fdbf5660 | ||
|
|
7ed5dfdb8f | ||
|
|
824c65eb38 | ||
|
|
e43eeeba4a | ||
|
|
9e2c7cc145 | ||
|
|
989598b9cd | ||
|
|
6a41942de6 | ||
|
|
d263072aca | ||
|
|
78c8467bf6 | ||
|
|
084908bd04 | ||
|
|
039a775ce4 | ||
|
|
dd9e80f067 | ||
|
|
a3a2af948e | ||
|
|
0bedf3ea59 | ||
|
|
1775ac4803 | ||
|
|
ae1a615863 | ||
|
|
a6ef1b8902 | ||
|
|
94d17b81d4 | ||
|
|
44a63d9cec | ||
|
|
e2b4b5a57e | ||
|
|
ec30aa323e | ||
|
|
95e9087c6e | ||
|
|
db38099557 | ||
|
|
164d5cdec9 | ||
|
|
c6b1076092 | ||
|
|
6aebe856da | ||
|
|
4737551918 | ||
|
|
c2cb79f700 | ||
|
|
d7c05984be | ||
|
|
55429e2f03 | ||
|
|
25ffe8b0e4 | ||
|
|
303a9d1110 | ||
|
|
de8c907c51 | ||
|
|
0fd1cabd60 | ||
|
|
8dd480735c | ||
|
|
676f1a1f0e | ||
|
|
ce75422126 | ||
|
|
3a097d6b15 | ||
|
|
9de1bf1020 | ||
|
|
93e5cf1c25 | ||
|
|
6e2376570b | ||
|
|
b20c4bf197 | ||
|
|
6ae1d92033 |
180
cypress/e2e/batch_creation.cy.js
Normal file
180
cypress/e2e/batch_creation.cy.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
describe("Batch Creation", () => {
|
||||||
|
it("creates a new batch", () => {
|
||||||
|
cy.login();
|
||||||
|
cy.wait(500);
|
||||||
|
cy.visit("/lms/batches");
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
|
// Open Settings
|
||||||
|
cy.get("span").contains("Learning").click();
|
||||||
|
cy.get("span").contains("Settings").click();
|
||||||
|
|
||||||
|
// Add a new member
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("span")
|
||||||
|
.contains(/^Members$/)
|
||||||
|
.click();
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("button")
|
||||||
|
.contains("New")
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const dateNow = Date.now();
|
||||||
|
const randomEmail = `testuser_${dateNow}@example.com`;
|
||||||
|
const randomName = `Test User ${dateNow}`;
|
||||||
|
|
||||||
|
cy.get("input[placeholder='Email']").type(randomEmail);
|
||||||
|
cy.get("input[placeholder='First Name']").type(randomName);
|
||||||
|
cy.get("button").contains("Add").click();
|
||||||
|
|
||||||
|
// Add evaluator
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("span")
|
||||||
|
.contains(/^Evaluators$/)
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("button")
|
||||||
|
.contains("New")
|
||||||
|
.click();
|
||||||
|
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
||||||
|
|
||||||
|
cy.get("input[placeholder='Email']").type(randomEvaluator);
|
||||||
|
cy.get("button").contains("Add").click();
|
||||||
|
cy.get("div").contains(randomEvaluator).should("be.visible").click();
|
||||||
|
|
||||||
|
cy.visit("/lms/batches");
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
|
// Create a batch
|
||||||
|
cy.get("button").contains("New").click();
|
||||||
|
cy.wait(500);
|
||||||
|
cy.url().should("include", "/batches/new/edit");
|
||||||
|
cy.get("label").contains("Title").type("Test Batch");
|
||||||
|
|
||||||
|
cy.get("label").contains("Start Date").type("2030-10-01");
|
||||||
|
cy.get("label").contains("End Date").type("2030-10-31");
|
||||||
|
cy.get("label").contains("Start Time").type("10:00");
|
||||||
|
cy.get("label").contains("End Time").type("11:00");
|
||||||
|
cy.get("label").contains("Timezone").type("IST");
|
||||||
|
cy.get("label").contains("Seat Count").type("10");
|
||||||
|
cy.get("label").contains("Published").click();
|
||||||
|
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Short Description")
|
||||||
|
.type("Test Batch Short Description to test the UI");
|
||||||
|
cy.get("div[contenteditable=true").invoke(
|
||||||
|
"text",
|
||||||
|
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Instructor */
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Instructors")
|
||||||
|
.parent()
|
||||||
|
.within(() => {
|
||||||
|
cy.get("input").click().type("evaluator");
|
||||||
|
cy.get("input")
|
||||||
|
.invoke("attr", "aria-controls")
|
||||||
|
.as("instructor_list_id");
|
||||||
|
});
|
||||||
|
cy.get("@instructor_list_id").then((instructor_list_id) => {
|
||||||
|
cy.get(`[id^=${instructor_list_id}`)
|
||||||
|
.should("be.visible")
|
||||||
|
.within(() => {
|
||||||
|
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.button("Save").click();
|
||||||
|
cy.wait(1000);
|
||||||
|
let batchName;
|
||||||
|
cy.url().then((url) => {
|
||||||
|
console.log(url);
|
||||||
|
batchName = url.split("/").pop();
|
||||||
|
cy.wrap(batchName).as("batchName");
|
||||||
|
});
|
||||||
|
cy.wait(500);
|
||||||
|
|
||||||
|
// View Batch
|
||||||
|
cy.wait(1000);
|
||||||
|
cy.visit("/lms/batches");
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
|
cy.url().should("include", "/lms/batches");
|
||||||
|
|
||||||
|
cy.get('[id^="headlessui-radiogroup-v-"]')
|
||||||
|
.find("span")
|
||||||
|
.contains("Upcoming")
|
||||||
|
.should("be.visible")
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get("@batchName").then((batchName) => {
|
||||||
|
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
|
||||||
|
cy.get("div").contains("Test Batch").should("be.visible");
|
||||||
|
cy.get("div")
|
||||||
|
.contains("Test Batch Short Description to test the UI")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span")
|
||||||
|
.contains("01 Oct 2030 - 31 Oct 2030")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span")
|
||||||
|
.contains("10:00 AM - 11:00 AM")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span").contains("IST").should("be.visible");
|
||||||
|
cy.get("a").contains("Evaluator").should("be.visible");
|
||||||
|
cy.get("div")
|
||||||
|
.contains("10")
|
||||||
|
.should("be.visible")
|
||||||
|
.get("span")
|
||||||
|
.contains("Seats Left")
|
||||||
|
.should("be.visible");
|
||||||
|
});
|
||||||
|
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get("div").contains("Test Batch").should("be.visible");
|
||||||
|
cy.get("div")
|
||||||
|
.contains("Test Batch Short Description to test the UI")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("a").contains("Evaluator").should("be.visible");
|
||||||
|
cy.get("span")
|
||||||
|
.contains("01 Oct 2030 - 31 Oct 2030")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span").contains("10:00 AM - 11:00 AM").should("be.visible");
|
||||||
|
cy.get("span").contains("IST").should("be.visible");
|
||||||
|
cy.get("div")
|
||||||
|
.contains("10")
|
||||||
|
.should("be.visible")
|
||||||
|
.get("span")
|
||||||
|
.contains("Seats Left")
|
||||||
|
.should("be.visible");
|
||||||
|
|
||||||
|
cy.get("p")
|
||||||
|
.contains(
|
||||||
|
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||||
|
)
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("button").contains("Manage Batch").click();
|
||||||
|
|
||||||
|
/* Add student to batch */
|
||||||
|
cy.get("button").contains("Add").click();
|
||||||
|
cy.get('div[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.first()
|
||||||
|
.find("button")
|
||||||
|
.eq(1)
|
||||||
|
.click();
|
||||||
|
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
|
||||||
|
cy.get("div").contains(randomEmail).click();
|
||||||
|
cy.get("button").contains("Submit").click();
|
||||||
|
|
||||||
|
// Verify Seat Count
|
||||||
|
cy.get("span").contains("Details").click();
|
||||||
|
cy.get("div")
|
||||||
|
.contains("9")
|
||||||
|
.should("be.visible")
|
||||||
|
.get("span")
|
||||||
|
.contains("Seats Left")
|
||||||
|
.should("be.visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
describe("Course Creation", () => {
|
describe("Course Creation", () => {
|
||||||
it("creates a new course", () => {
|
it("creates a new course", () => {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.wait(1000);
|
cy.wait(500);
|
||||||
cy.visit("/lms/courses");
|
cy.visit("/lms/courses");
|
||||||
|
|
||||||
|
// Close onboarding modal
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("button").contains("New").click();
|
cy.get("button").contains("New").click();
|
||||||
cy.wait(1000);
|
cy.wait(500);
|
||||||
cy.url().should("include", "/courses/new/edit");
|
cy.url().should("include", "/courses/new/edit");
|
||||||
|
|
||||||
cy.get("label").contains("Title").type("Test Course");
|
cy.get("label").contains("Title").type("Test Course");
|
||||||
@@ -96,7 +99,8 @@ describe("Course Creation", () => {
|
|||||||
// View Course
|
// View Course
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.visit("/lms");
|
cy.visit("/lms");
|
||||||
cy.wait(500);
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
cy.url().should("include", "/lms/courses");
|
cy.url().should("include", "/lms/courses");
|
||||||
cy.get(".grid a:first").within(() => {
|
cy.get(".grid a:first").within(() => {
|
||||||
cy.get("div").contains("Test Course");
|
cy.get("div").contains("Test Course");
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
import "cypress-file-upload";
|
import "cypress-file-upload";
|
||||||
|
import "cypress-real-events";
|
||||||
|
|
||||||
Cypress.Commands.add("login", (email, password) => {
|
Cypress.Commands.add("login", (email, password) => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
@@ -68,3 +69,18 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
|||||||
element.dispatchEvent(event);
|
element.dispatchEvent(event);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("closeOnboardingModal", () => {
|
||||||
|
cy.wait(500);
|
||||||
|
cy.get("body").then(($body) => {
|
||||||
|
// Check if any element with class including 'z-50' exists
|
||||||
|
if ($body.find('[class*="z-50"]').length > 0) {
|
||||||
|
cy.get('[class*="z-50"]')
|
||||||
|
.find('button:has(svg[class*="feather-x"])')
|
||||||
|
.realClick();
|
||||||
|
cy.wait(1000);
|
||||||
|
} else {
|
||||||
|
cy.log("Onboarding modal not found, skipping close.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
1
frappe-ui
Submodule
1
frappe-ui
Submodule
Submodule frappe-ui added at fd5252663b
23
frontend/components.d.ts
vendored
23
frontend/components.d.ts
vendored
@@ -27,9 +27,9 @@ declare module 'vue' {
|
|||||||
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
|
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
|
||||||
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
|
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
|
||||||
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
|
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
|
||||||
BrandSettings: typeof import('./src/components/BrandSettings.vue')['default']
|
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
|
||||||
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
|
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
|
||||||
Categories: typeof import('./src/components/Categories.vue')['default']
|
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
|
||||||
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
||||||
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
|
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
|
||||||
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||||
@@ -47,11 +47,14 @@ declare module 'vue' {
|
|||||||
Discussions: typeof import('./src/components/Discussions.vue')['default']
|
Discussions: typeof import('./src/components/Discussions.vue')['default']
|
||||||
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
|
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
|
||||||
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
|
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
|
||||||
|
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
||||||
|
EmailTemplates: typeof import('./src/components/Settings/EmailTemplates.vue')['default']
|
||||||
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
|
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
|
||||||
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
|
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
|
||||||
Evaluators: typeof import('./src/components/Evaluators.vue')['default']
|
Evaluators: typeof import('./src/components/Settings/Evaluators.vue')['default']
|
||||||
Event: typeof import('./src/components/Modals/Event.vue')['default']
|
Event: typeof import('./src/components/Modals/Event.vue')['default']
|
||||||
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
|
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
|
||||||
|
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
|
||||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||||
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
||||||
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
||||||
@@ -62,28 +65,30 @@ declare module 'vue' {
|
|||||||
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
||||||
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
||||||
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
|
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
|
||||||
|
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
|
||||||
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
|
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
|
||||||
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
|
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
|
||||||
Members: typeof import('./src/components/Members.vue')['default']
|
Members: typeof import('./src/components/Settings/Members.vue')['default']
|
||||||
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
|
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
|
||||||
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
||||||
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
||||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||||
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||||
PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default']
|
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
|
||||||
Play: typeof import('./src/components/Icons/Play.vue')['default']
|
Play: typeof import('./src/components/Icons/Play.vue')['default']
|
||||||
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
|
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
|
||||||
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
||||||
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
||||||
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
|
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
|
||||||
|
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
|
||||||
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
|
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
|
||||||
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
|
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SettingDetails: typeof import('./src/components/SettingDetails.vue')['default']
|
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
|
||||||
SettingFields: typeof import('./src/components/SettingFields.vue')['default']
|
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
|
||||||
Settings: typeof import('./src/components/Modals/Settings.vue')['default']
|
Settings: typeof import('./src/components/Settings/Settings.vue')['default']
|
||||||
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
|
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
|
||||||
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
|
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
|
||||||
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
|
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
|
||||||
@@ -94,5 +99,7 @@ declare module 'vue' {
|
|||||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||||
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
||||||
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
||||||
|
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
|
||||||
|
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"codemirror-editor-vue3": "^2.8.0",
|
"codemirror-editor-vue3": "^2.8.0",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.143",
|
"frappe-ui": "^0.1.147",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-vue-next": "^0.383.0",
|
"lucide-vue-next": "^0.383.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
|
|||||||
@@ -9,20 +9,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { FrappeUIProvider } from 'frappe-ui'
|
import { FrappeUIProvider } from 'frappe-ui'
|
||||||
import { Dialogs } from '@/utils/dialogs'
|
import { Dialogs } from '@/utils/dialogs'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useScreenSize } from './utils/composables'
|
import { useScreenSize } from './utils/composables'
|
||||||
import DesktopLayout from './components/DesktopLayout.vue'
|
import DesktopLayout from './components/DesktopLayout.vue'
|
||||||
import MobileLayout from './components/MobileLayout.vue'
|
import MobileLayout from './components/MobileLayout.vue'
|
||||||
import NoSidebarLayout from './components/NoSidebarLayout.vue'
|
import NoSidebarLayout from './components/NoSidebarLayout.vue'
|
||||||
import { stopSession } from '@/telemetry'
|
|
||||||
import { init as initTelemetry } from '@/telemetry'
|
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { posthogSettings } from '@/telemetry'
|
||||||
|
|
||||||
const screenSize = useScreenSize()
|
const screenSize = useScreenSize()
|
||||||
let { userResource } = usersStore()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const noSidebar = ref(false)
|
const noSidebar = ref(false)
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
if (to.query.fromLesson || to.path === '/persona') {
|
if (to.query.fromLesson || to.path === '/persona') {
|
||||||
@@ -39,17 +38,18 @@ const Layout = computed(() => {
|
|||||||
}
|
}
|
||||||
if (screenSize.width < 640) {
|
if (screenSize.width < 640) {
|
||||||
return MobileLayout
|
return MobileLayout
|
||||||
} else {
|
|
||||||
return DesktopLayout
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
return DesktopLayout
|
||||||
if (userResource.data) await initTelemetry()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
noSidebar.value = false
|
noSidebar.value = false
|
||||||
stopSession()
|
})
|
||||||
|
|
||||||
|
watch(userResource, () => {
|
||||||
|
if (userResource.data) {
|
||||||
|
posthogSettings.reload()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -181,8 +181,16 @@
|
|||||||
import UserDropdown from '@/components/UserDropdown.vue'
|
import UserDropdown from '@/components/UserDropdown.vue'
|
||||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import { useStorage } from '@vueuse/core'
|
import {
|
||||||
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue'
|
ref,
|
||||||
|
onMounted,
|
||||||
|
inject,
|
||||||
|
watch,
|
||||||
|
reactive,
|
||||||
|
markRaw,
|
||||||
|
h,
|
||||||
|
onUnmounted,
|
||||||
|
} from 'vue'
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '../utils'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
@@ -244,6 +252,7 @@ const iconProps = {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
addNotifications()
|
addNotifications()
|
||||||
setSidebarLinks()
|
setSidebarLinks()
|
||||||
|
setUpOnboarding()
|
||||||
socket.on('publish_lms_notifications', (data) => {
|
socket.on('publish_lms_notifications', (data) => {
|
||||||
unreadNotifications.reload()
|
unreadNotifications.reload()
|
||||||
})
|
})
|
||||||
@@ -388,10 +397,6 @@ const deletePage = (link) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSidebarFromStorage = () => {
|
|
||||||
return useStorage('sidebar_is_collapsed', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
|
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@@ -438,6 +443,7 @@ const steps = reactive([
|
|||||||
title: __('Add your first chapter'),
|
title: __('Add your first chapter'),
|
||||||
icon: markRaw(h(FolderTree, iconProps)),
|
icon: markRaw(h(FolderTree, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_course',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let course = await getFirstCourse()
|
let course = await getFirstCourse()
|
||||||
@@ -453,6 +459,7 @@ const steps = reactive([
|
|||||||
title: __('Add your first lesson'),
|
title: __('Add your first lesson'),
|
||||||
icon: markRaw(h(FileText, iconProps)),
|
icon: markRaw(h(FileText, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_chapter',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let course = await getFirstCourse()
|
let course = await getFirstCourse()
|
||||||
@@ -471,6 +478,7 @@ const steps = reactive([
|
|||||||
title: __('Create your first quiz'),
|
title: __('Create your first quiz'),
|
||||||
icon: markRaw(h(CircleHelp, iconProps)),
|
icon: markRaw(h(CircleHelp, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_course',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
router.push({ name: 'Quizzes' })
|
router.push({ name: 'Quizzes' })
|
||||||
@@ -502,6 +510,7 @@ const steps = reactive([
|
|||||||
title: __('Add students to your batch'),
|
title: __('Add students to your batch'),
|
||||||
icon: markRaw(h(UserPlus, iconProps)),
|
icon: markRaw(h(UserPlus, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_batch',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let batch = await getFirstBatch()
|
let batch = await getFirstBatch()
|
||||||
@@ -522,6 +531,7 @@ const steps = reactive([
|
|||||||
title: __('Add courses to your batch'),
|
title: __('Add courses to your batch'),
|
||||||
icon: markRaw(h(BookText, iconProps)),
|
icon: markRaw(h(BookText, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_batch',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let batch = await getFirstBatch()
|
let batch = await getFirstBatch()
|
||||||
@@ -625,4 +635,8 @@ watch(userResource, () => {
|
|||||||
const redirectToWebsite = () => {
|
const redirectToWebsite = () => {
|
||||||
window.open('https://frappe.io/learning', '_blank')
|
window.open('https://frappe.io/learning', '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_lms_notifications')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col border hover:border-outline-gray-4 rounded-md p-4 h-full"
|
class="flex flex-col border hover:border-outline-gray-3 rounded-md p-4 h-full"
|
||||||
style="min-height: 150px"
|
style="min-height: 150px"
|
||||||
>
|
>
|
||||||
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
|
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ const courses = createResource({
|
|||||||
params: {
|
params: {
|
||||||
batch: props.batch,
|
batch: props.batch,
|
||||||
},
|
},
|
||||||
cache: ['batchCourses', props.batchName],
|
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="user.data?.is_student">
|
<div v-if="user.data?.is_student">
|
||||||
<div
|
<div>
|
||||||
v-if="feedbackList.data?.length"
|
<div class="leading-5 mb-4">
|
||||||
class="bg-surface-blue-2 text-blue-700 p-2 rounded-md mb-5"
|
<div v-if="readOnly">
|
||||||
>
|
{{ __('Thank you for providing your feedback.') }}
|
||||||
{{ __('Thank you for providing your feedback!') }}
|
<span
|
||||||
</div>
|
@click="showFeedbackForm = !showFeedbackForm"
|
||||||
<div v-else class="flex justify-between items-center mb-5">
|
class="underline cursor-pointer"
|
||||||
<div class="text-lg font-semibold">
|
>{{ __('Click here') }}</span
|
||||||
{{ __('Help Us Improve') }}
|
>
|
||||||
|
{{ __('to view your feedback.') }}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ __('Help us improve by providing your feedback.') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button @click="submitFeedback()">
|
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
|
||||||
{{ __('Submit') }}
|
<div class="space-y-4">
|
||||||
</Button>
|
<Rating
|
||||||
</div>
|
v-for="key in ratingKeys"
|
||||||
<div class="space-y-8">
|
v-model="feedback[key]"
|
||||||
<div class="flex items-center justify-between">
|
:label="__(convertToTitleCase(key))"
|
||||||
<Rating
|
:readonly="readOnly"
|
||||||
v-for="key in ratingKeys"
|
/>
|
||||||
v-model="feedback[key]"
|
</div>
|
||||||
:label="__(convertToTitleCase(key))"
|
<FormControl
|
||||||
|
v-model="feedback.feedback"
|
||||||
|
type="textarea"
|
||||||
|
:label="__('Feedback')"
|
||||||
|
:rows="9"
|
||||||
:readonly="readOnly"
|
:readonly="readOnly"
|
||||||
/>
|
/>
|
||||||
|
<Button v-if="!readOnly" @click="submitFeedback">
|
||||||
|
{{ __('Submit Feedback') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
|
||||||
v-model="feedback.feedback"
|
|
||||||
type="textarea"
|
|
||||||
:label="__('Feedback')"
|
|
||||||
:rows="7"
|
|
||||||
:readonly="readOnly"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="feedbackList.data?.length">
|
<div v-else-if="feedbackList.data?.length">
|
||||||
<div class="text-lg font-semibold mb-5">
|
<div class="leading-5 text-sm mb-2 mt-5">
|
||||||
{{ __('Average of Feedback Received') }}
|
{{ __('Average Feedback Received') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-10">
|
<div class="space-y-4">
|
||||||
<Rating
|
<Rating
|
||||||
v-for="key in ratingKeys"
|
v-for="key in ratingKeys"
|
||||||
v-model="average[key]"
|
v-model="average[key]"
|
||||||
@@ -47,81 +52,32 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-lg font-semibold mb-5">
|
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
|
||||||
{{ __('All Feedback') }}
|
{{ __('View all feedback') }}
|
||||||
</div>
|
</Button>
|
||||||
<ListView
|
|
||||||
:columns="feedbackColumns"
|
|
||||||
:rows="feedbackList.data"
|
|
||||||
row-key="name"
|
|
||||||
:options="{
|
|
||||||
showTooltip: false,
|
|
||||||
rowHeight: 'h-16',
|
|
||||||
selectable: false,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ListHeader
|
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
|
||||||
></ListHeader>
|
|
||||||
<ListRows>
|
|
||||||
<ListRow
|
|
||||||
:row="row"
|
|
||||||
v-for="row in feedbackList.data"
|
|
||||||
class="group cursor-pointer feedback-list"
|
|
||||||
>
|
|
||||||
<template #default="{ column, item }">
|
|
||||||
<ListRowItem
|
|
||||||
:item="row[column.key]"
|
|
||||||
:align="column.align"
|
|
||||||
class="text-sm"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<div v-if="column.key == 'member_name'">
|
|
||||||
<Avatar
|
|
||||||
class="flex"
|
|
||||||
:image="row['member_image']"
|
|
||||||
:label="item"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-if="ratingKeys.includes(column.key)">
|
|
||||||
<Rating v-model="row[column.key]" :readonly="true" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="leading-5">
|
|
||||||
{{ row[column.key] }}
|
|
||||||
</div>
|
|
||||||
</ListRowItem>
|
|
||||||
</template>
|
|
||||||
</ListRow>
|
|
||||||
</ListRows>
|
|
||||||
</ListView>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm italic text-center text-ink-gray-7 mt-5">
|
<div v-else class="text-ink-gray-7 mt-5 leading-5">
|
||||||
{{ __('No feedback received yet.') }}
|
{{ __('No feedback received yet.') }}
|
||||||
</div>
|
</div>
|
||||||
|
<FeedbackModal
|
||||||
|
v-if="feedbackList.data?.length"
|
||||||
|
v-model="showAllFeedback"
|
||||||
|
:feedbackList="feedbackList.data"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
|
import { inject, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { convertToTitleCase } from '@/utils'
|
import { convertToTitleCase } from '@/utils'
|
||||||
import {
|
import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
|
||||||
Avatar,
|
import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
|
||||||
Button,
|
|
||||||
createListResource,
|
|
||||||
FormControl,
|
|
||||||
ListView,
|
|
||||||
ListHeader,
|
|
||||||
ListRows,
|
|
||||||
ListRow,
|
|
||||||
ListRowItem,
|
|
||||||
Rating,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const ratingKeys = ['content', 'instructors', 'value']
|
const ratingKeys = ['content', 'instructors', 'value']
|
||||||
const readOnly = ref(false)
|
const readOnly = ref(false)
|
||||||
const average = reactive({})
|
const average = reactive({})
|
||||||
const feedback = reactive({})
|
const feedback = reactive({})
|
||||||
|
const showFeedbackForm = ref(true)
|
||||||
|
const showAllFeedback = ref(false)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -167,6 +123,7 @@ watch(
|
|||||||
if (feedbackList.data.length) {
|
if (feedbackList.data.length) {
|
||||||
let data = feedbackList.data
|
let data = feedbackList.data
|
||||||
readOnly.value = true
|
readOnly.value = true
|
||||||
|
showFeedbackForm.value = false
|
||||||
|
|
||||||
ratingKeys.forEach((key) => {
|
ratingKeys.forEach((key) => {
|
||||||
average[key] = 0
|
average[key] = 0
|
||||||
@@ -201,40 +158,11 @@ const submitFeedback = () => {
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
feedbackList.reload()
|
feedbackList.reload()
|
||||||
|
showFeedbackForm.value = false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedbackColumns = computed(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Member',
|
|
||||||
key: 'member_name',
|
|
||||||
width: '10rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Feedback',
|
|
||||||
key: 'feedback',
|
|
||||||
width: '15rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Content',
|
|
||||||
key: 'content',
|
|
||||||
width: '9rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Instructors',
|
|
||||||
key: 'instructors',
|
|
||||||
width: '9rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Value',
|
|
||||||
key: 'value',
|
|
||||||
width: '9rem',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.feedback-list > button > div {
|
.feedback-list > button > div {
|
||||||
|
|||||||
@@ -6,103 +6,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-4 gap-5 mb-8">
|
<div class="grid grid-cols-4 gap-5 mb-8">
|
||||||
<div
|
<NumberChart
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
class="border rounded-md"
|
||||||
>
|
:config="{ title: __('Students'), value: students.data?.length || 0 }"
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
/>
|
||||||
<User class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
<NumberChart
|
||||||
<div class="flex items-center space-x-2">
|
class="border rounded-md"
|
||||||
<span class="font-semibold">
|
:config="{
|
||||||
{{ students.data?.length }}
|
title: __('Certified'),
|
||||||
</span>
|
value: certificationCount.data || 0,
|
||||||
<span class="">
|
}"
|
||||||
{{ __('Students') }}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
<NumberChart
|
||||||
</div>
|
class="border rounded-md"
|
||||||
|
:config="{
|
||||||
<div
|
title: __('Courses'),
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
value: batch.data.courses?.length || 0,
|
||||||
>
|
}"
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
/>
|
||||||
<GraduationCap class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
<NumberChart
|
||||||
<div class="flex items-center space-x-2">
|
class="border rounded-md"
|
||||||
<span class="font-semibold">
|
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
|
||||||
{{ certificationCount.data }}
|
|
||||||
</span>
|
|
||||||
<span class="">
|
|
||||||
{{ __('Certified') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
|
||||||
>
|
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
|
||||||
<BookOpen class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span class="font-semibold">
|
|
||||||
{{ batch.data.courses?.length }}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{{ __('Courses') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
|
||||||
>
|
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
|
||||||
<ShieldCheck class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span class="font-semibold">
|
|
||||||
{{ assessmentCount }}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{{ __('Assessments') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="showProgressChart" class="mb-8">
|
|
||||||
<div class="text-ink-gray-7 font-medium">
|
|
||||||
{{ __('Progress') }}
|
|
||||||
</div>
|
|
||||||
<ApexChart
|
|
||||||
:options="chartOptions"
|
|
||||||
:series="chartData"
|
|
||||||
type="bar"
|
|
||||||
:height="chartData[0].data.length * 30 + 100"
|
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
class="flex items-center justify-center text-sm text-ink-gray-7 space-x-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-sm"
|
|
||||||
:style="{ 'background-color': theme.colors.green[600] }"
|
|
||||||
></div>
|
|
||||||
<div>
|
|
||||||
{{ __('Courses') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-sm"
|
|
||||||
:style="{ 'background-color': theme.colors.blue[600] }"
|
|
||||||
></div>
|
|
||||||
<div>
|
|
||||||
{{ __('Assessments') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AxisChart
|
||||||
|
v-if="showProgressChart"
|
||||||
|
:config="{
|
||||||
|
data: chartData,
|
||||||
|
title: __('Batch Summary'),
|
||||||
|
subtitle: __('Progress of students in courses and assessments'),
|
||||||
|
xAxis: {
|
||||||
|
key: 'task',
|
||||||
|
title: 'Tasks',
|
||||||
|
type: 'category',
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
title: __('Number of Students'),
|
||||||
|
echartOptions: {
|
||||||
|
minInterval: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
swapXY: true,
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: 'bar',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -214,6 +170,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
AxisChart,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
@@ -224,6 +181,7 @@ import {
|
|||||||
ListRows,
|
ListRows,
|
||||||
ListView,
|
ListView,
|
||||||
ListRowItem,
|
ListRowItem,
|
||||||
|
NumberChart,
|
||||||
toast,
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import {
|
import {
|
||||||
@@ -245,7 +203,6 @@ const showStudentModal = ref(false)
|
|||||||
const showStudentProgressModal = ref(false)
|
const showStudentProgressModal = ref(false)
|
||||||
const selectedStudent = ref(null)
|
const selectedStudent = ref(null)
|
||||||
const chartData = ref(null)
|
const chartData = ref(null)
|
||||||
const chartOptions = ref(null)
|
|
||||||
const showProgressChart = ref(false)
|
const showProgressChart = ref(false)
|
||||||
const assessmentCount = ref(0)
|
const assessmentCount = ref(0)
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
@@ -333,96 +290,49 @@ const removeStudents = (selections, unselectAll) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getChartData = () => {
|
const getChartData = () => {
|
||||||
let categories = {}
|
let tasks = []
|
||||||
|
let data = []
|
||||||
|
|
||||||
if (!students.data?.length) return []
|
students.data.forEach((row) => {
|
||||||
|
tasks = countAssessments(row, tasks)
|
||||||
Object.keys(students.data[0].courses).forEach((course) => {
|
tasks = countCourses(row, tasks)
|
||||||
categories[course] = {
|
|
||||||
value: 0,
|
|
||||||
type: 'course',
|
|
||||||
label: course,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Object.keys(students.data?.[0].assessments).forEach((assessment) => {
|
tasks.forEach((task) => {
|
||||||
categories[assessment] = {
|
data.push({
|
||||||
value: 0,
|
task: task.label,
|
||||||
type: 'assessment',
|
value: task.value,
|
||||||
label: assessment,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
students.data.forEach((student) => {
|
|
||||||
Object.keys(student.courses).forEach((course) => {
|
|
||||||
if (student.courses[course] === 100) {
|
|
||||||
categories[course].value += 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Object.keys(student.assessments).forEach((assessment) => {
|
|
||||||
if (student.assessments[assessment].result === 'Pass') {
|
|
||||||
categories[assessment].value += 1
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
return data
|
||||||
chartOptions.value = getChartOptions(categories)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: __('Completed by Students'),
|
|
||||||
data: Object.values(categories).map((item) => item.value),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChartOptions = (categories) => {
|
const countAssessments = (row, tasks) => {
|
||||||
const courseColor = theme.colors.green[700]
|
Object.keys(row.assessments).forEach((assessment) => {
|
||||||
const assessmentColor = theme.colors.blue[700]
|
if (row.assessments[assessment].result === 'Pass') {
|
||||||
const maxY =
|
tasks.filter((task) => task.label === assessment).length
|
||||||
students.data?.length % 5
|
? tasks.filter((task) => task.label === assessment)[0].value++
|
||||||
? students.data?.length + (5 - (students.data?.length % 5))
|
: tasks.push({
|
||||||
: students.data?.length
|
value: 1,
|
||||||
|
label: assessment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const countCourses = (row, tasks) => {
|
||||||
chart: {
|
Object.keys(row.courses).forEach((course) => {
|
||||||
type: 'bar',
|
if (row.courses[course] === 100) {
|
||||||
toolbar: {
|
tasks.filter((task) => task.label === course).length
|
||||||
show: false,
|
? tasks.filter((task) => task.label === course)[0].value++
|
||||||
},
|
: tasks.push({
|
||||||
},
|
value: 1,
|
||||||
plotOptions: {
|
label: course,
|
||||||
bar: {
|
})
|
||||||
distributed: true,
|
}
|
||||||
borderRadius: 3,
|
})
|
||||||
borderRadiusApplication: 'end',
|
return tasks
|
||||||
horizontal: true,
|
|
||||||
barHeight: '40%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
colors: Object.values(categories).map((item) =>
|
|
||||||
item.type === 'course' ? courseColor : assessmentColor
|
|
||||||
),
|
|
||||||
xaxis: {
|
|
||||||
categories: Object.values(categories).map((item) => item.label),
|
|
||||||
labels: {
|
|
||||||
style: {
|
|
||||||
fontSize: '10px',
|
|
||||||
},
|
|
||||||
rotate: 0,
|
|
||||||
formatter: function (value) {
|
|
||||||
return value.length > 30 ? `${value.substring(0, 30)}...` : value
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
max: maxY,
|
|
||||||
min: 0,
|
|
||||||
stepSize: 10,
|
|
||||||
tickAmount: maxY / 5,
|
|
||||||
/* reversed: true */
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(students, () => {
|
watch(students, () => {
|
||||||
@@ -442,8 +352,3 @@ const certificationCount = createResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
.apexcharts-legend {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col min-h-0">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="text-xl font-semibold mb-5 text-ink-gray-9">
|
|
||||||
{{ label }}
|
|
||||||
</div>
|
|
||||||
<Button @click="() => showCategoryForm()">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
|
||||||
<X v-else class="h-3 w-3 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
{{ showForm ? __('Close') : __('New') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="showForm"
|
|
||||||
class="flex items-center justify-between my-4 space-x-2"
|
|
||||||
>
|
|
||||||
<FormControl
|
|
||||||
ref="categoryInput"
|
|
||||||
v-model="category"
|
|
||||||
:placeholder="__('Category Name')"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<Button @click="addCategory()" variant="subtle">
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-y-scroll">
|
|
||||||
<div class="text-base space-y-2">
|
|
||||||
<FormControl
|
|
||||||
:value="cat.category"
|
|
||||||
type="text"
|
|
||||||
v-for="cat in categories.data"
|
|
||||||
@change.stop="(e) => update(cat.name, e.target.value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
createListResource,
|
|
||||||
createResource,
|
|
||||||
debounce,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { Plus, X } from 'lucide-vue-next'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const showForm = ref(false)
|
|
||||||
const category = ref(null)
|
|
||||||
const categoryInput = ref(null)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const categories = createListResource({
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
fields: ['name', 'category'],
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const newCategory = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
category: category.value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const addCategory = () => {
|
|
||||||
newCategory.submit(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
categories.reload()
|
|
||||||
category.value = null
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const showCategoryForm = () => {
|
|
||||||
showForm.value = !showForm.value
|
|
||||||
setTimeout(() => {
|
|
||||||
categoryInput.value.$el.querySelector('input').focus()
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateCategory = createResource({
|
|
||||||
url: 'frappe.client.rename_doc',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
old_name: values.name,
|
|
||||||
new_name: values.category,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const update = (name, value) => {
|
|
||||||
updateCategory.submit(
|
|
||||||
{
|
|
||||||
name: name,
|
|
||||||
category: value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
categories.reload()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -55,9 +55,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ComboboxOption>
|
</ComboboxOption>
|
||||||
|
<div class="h-10"></div>
|
||||||
<div
|
<div
|
||||||
v-if="attrs.onCreate"
|
v-if="attrs.onCreate"
|
||||||
class="absolute bottom-2 left-1 w-[98%] pt-2 bg-white border-t"
|
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
v-if="parseInt(course.data.rating) > 0"
|
v-if="parseInt(course.data.rating) > 0"
|
||||||
class="flex items-center text-ink-gray-9"
|
class="flex items-center text-ink-gray-9"
|
||||||
>
|
>
|
||||||
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" />
|
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
{{ course.data.rating }} {{ __('Rating') }}
|
{{ course.data.rating }} {{ __('Rating') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
|
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
|
||||||
import { computed, inject } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import { Badge, Button, createResource, toast } from 'frappe-ui'
|
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
|
||||||
import { formatAmount } from '@/utils/'
|
import { formatAmount } from '@/utils/'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -175,15 +175,11 @@ function enrollStudent() {
|
|||||||
toast.success(__('You need to login first to enroll for this course'))
|
toast.success(__('You need to login first to enroll for this course'))
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||||
}, 1000)
|
}, 500)
|
||||||
} else {
|
} else {
|
||||||
const enrollStudentResource = createResource({
|
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
|
||||||
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
course: props.course.data.name,
|
||||||
})
|
})
|
||||||
enrollStudentResource
|
|
||||||
.submit({
|
|
||||||
course: props.course.data.name,
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
capture('enrolled_in_course', {
|
capture('enrolled_in_course', {
|
||||||
course: props.course.data.name,
|
course: props.course.data.name,
|
||||||
@@ -198,7 +194,11 @@ function enrollStudent() {
|
|||||||
lessonNumber: 1,
|
lessonNumber: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, 2000)
|
}, 1000)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
console.error(err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,14 @@
|
|||||||
<span class="text-ink-gray-7">
|
<span class="text-ink-gray-7">
|
||||||
{{ review.creation }}
|
{{ review.creation }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex mt-2">
|
<div class="flex mt-2 space-x-1">
|
||||||
<Star
|
<Star
|
||||||
v-for="index in 5"
|
v-for="index in 5"
|
||||||
class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2"
|
class="size-4 text-transparent rounded-sm"
|
||||||
:class="
|
:class="
|
||||||
index <= Math.ceil(review.rating)
|
index <= Math.ceil(review.rating)
|
||||||
? 'fill-orange-500'
|
? 'fill-yellow-500'
|
||||||
: 'fill-gray-600'
|
: 'fill-gray-300'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
|||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
import { ref, inject, onMounted } from 'vue'
|
import { ref, inject, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
const newReply = ref('')
|
const newReply = ref('')
|
||||||
@@ -251,4 +251,10 @@ const deleteReply = (reply) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_message')
|
||||||
|
socket.off('update_message')
|
||||||
|
socket.off('delete_message')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { singularize, timeAgo } from '../utils'
|
import { singularize, timeAgo } from '../utils'
|
||||||
import { ref, onMounted, inject } from 'vue'
|
import { ref, onMounted, inject, onUnmounted } from 'vue'
|
||||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||||
import { MessageSquareText } from 'lucide-vue-next'
|
import { MessageSquareText } from 'lucide-vue-next'
|
||||||
@@ -153,4 +153,8 @@ const showReplies = (topic) => {
|
|||||||
const openTopicModal = () => {
|
const openTopicModal = () => {
|
||||||
showTopicModal.value = true
|
showTopicModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('new_discussion_topic')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-4"
|
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3"
|
||||||
>
|
>
|
||||||
<div class="flex space-x-4 mb-4">
|
<div class="flex space-x-4 mb-4">
|
||||||
<div class="flex flex-col space-y-2 flex-1">
|
<div class="flex flex-col space-y-2 flex-1">
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between mb-5">
|
<div
|
||||||
|
v-if="hasPermission() && !props.zoomAccount"
|
||||||
|
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3"
|
||||||
|
>
|
||||||
|
<AlertCircle class="size-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('Please add a zoom account to the batch to create live classes.') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<div class="text-lg font-semibold text-ink-gray-9">
|
<div class="text-lg font-semibold text-ink-gray-9">
|
||||||
{{ __('Live Class') }}
|
{{ __('Live Class') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -12,10 +22,18 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
<div v-if="liveClasses.data?.length" class="grid grid-cols-3 gap-5 mt-5">
|
||||||
<div
|
<div
|
||||||
v-for="cls in liveClasses.data"
|
v-for="cls in liveClasses.data"
|
||||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 p-3"
|
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
|
||||||
|
:class="{
|
||||||
|
'cursor-pointer': hasPermission() && cls.attendees > 0,
|
||||||
|
}"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
openAttendanceModal(cls)
|
||||||
|
}
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||||
{{ cls.title }}
|
{{ cls.title }}
|
||||||
@@ -23,7 +41,7 @@
|
|||||||
<div class="short-introduction">
|
<div class="short-introduction">
|
||||||
{{ cls.description }}
|
{{ cls.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="mt-auto space-y-3">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||||
<span>
|
<span>
|
||||||
@@ -33,18 +51,20 @@
|
|||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Clock class="w-4 h-4 stroke-1.5" />
|
<Clock class="w-4 h-4 stroke-1.5" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(cls.time) }}
|
{{ formatTime(cls.time) }} -
|
||||||
|
{{ dayjs(getClassEnd(cls)).format('HH:mm') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="cls.date >= dayjs().format('YYYY-MM-DD')"
|
v-if="canAccessClass(cls)"
|
||||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||||
:href="cls.start_url"
|
:href="cls.start_url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||||
|
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||||
>
|
>
|
||||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||||
{{ __('Start') }}
|
{{ __('Start') }}
|
||||||
@@ -58,42 +78,63 @@
|
|||||||
{{ __('Join') }}
|
{{ __('Join') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex items-center space-x-2 text-yellow-700">
|
<Tooltip
|
||||||
<Info class="w-4 h-4 stroke-1.5" />
|
v-else-if="hasClassEnded(cls)"
|
||||||
<span>
|
:text="__('This class has ended')"
|
||||||
{{ __('This class has ended') }}
|
placement="right"
|
||||||
</span>
|
>
|
||||||
</div>
|
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||||
|
<Info class="w-4 h-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('Ended') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm italic text-ink-gray-5">
|
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
|
||||||
{{ __('No live classes scheduled') }}
|
{{ __('No live classes scheduled') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LiveClassModal
|
<LiveClassModal
|
||||||
:batch="props.batch"
|
:batch="props.batch"
|
||||||
|
:zoomAccount="props.zoomAccount"
|
||||||
v-model="showLiveClassModal"
|
v-model="showLiveClassModal"
|
||||||
v-model:reloadLiveClasses="liveClasses"
|
v-model:reloadLiveClasses="liveClasses"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createListResource, Button } from 'frappe-ui'
|
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||||
import { Plus, Clock, Calendar, Video, Monitor, Info } from 'lucide-vue-next'
|
import {
|
||||||
import { inject } from 'vue'
|
Plus,
|
||||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
Clock,
|
||||||
import { ref } from 'vue'
|
Calendar,
|
||||||
|
Video,
|
||||||
|
Monitor,
|
||||||
|
Info,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { inject, ref } from 'vue'
|
||||||
import { formatTime } from '@/utils/'
|
import { formatTime } from '@/utils/'
|
||||||
|
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||||
|
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showLiveClassModal = ref(false)
|
const showLiveClassModal = ref(false)
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
|
const showAttendance = ref(false)
|
||||||
|
const attendanceFor = ref(null)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
zoomAccount: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const liveClasses = createListResource({
|
const liveClasses = createListResource({
|
||||||
@@ -106,6 +147,8 @@ const liveClasses = createListResource({
|
|||||||
'description',
|
'description',
|
||||||
'time',
|
'time',
|
||||||
'date',
|
'date',
|
||||||
|
'duration',
|
||||||
|
'attendees',
|
||||||
'start_url',
|
'start_url',
|
||||||
'join_url',
|
'join_url',
|
||||||
'owner',
|
'owner',
|
||||||
@@ -120,8 +163,38 @@ const openLiveClassModal = () => {
|
|||||||
|
|
||||||
const canCreateClass = () => {
|
const canCreateClass = () => {
|
||||||
if (readOnlyMode) return false
|
if (readOnlyMode) return false
|
||||||
|
if (!props.zoomAccount) return false
|
||||||
|
return hasPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = () => {
|
||||||
return user.data?.is_moderator || user.data?.is_evaluator
|
return user.data?.is_moderator || user.data?.is_evaluator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canAccessClass = (cls) => {
|
||||||
|
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||||
|
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||||
|
if (hasClassEnded(cls)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getClassEnd = (cls) => {
|
||||||
|
const classStart = new Date(`${cls.date}T${cls.time}`)
|
||||||
|
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasClassEnded = (cls) => {
|
||||||
|
const classEnd = getClassEnd(cls)
|
||||||
|
const now = new Date()
|
||||||
|
return now > classEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAttendanceModal = (cls) => {
|
||||||
|
if (!hasPermission()) return
|
||||||
|
if (cls.attendees <= 0) return
|
||||||
|
showAttendance.value = true
|
||||||
|
attendanceFor.value = cls
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.short-introduction {
|
.short-introduction {
|
||||||
|
|||||||
@@ -1,60 +1,55 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col relative">
|
||||||
<div class="h-full pb-10" id="scrollContainer">
|
<div class="h-full pb-10" id="scrollContainer">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="sidebarSettings.data"
|
<div class="relative z-20">
|
||||||
class="fixed flex items-center justify-around border-t border-outline-gray-2 bottom-0 z-10 w-full bg-surface-white standalone:pb-4"
|
<!-- Dropdown menu -->
|
||||||
:style="{
|
<div
|
||||||
gridTemplateColumns: `repeat(${
|
class="fixed bottom-16 right-2 w-[80%] rounded-md bg-surface-white text-base p-5 space-y-4 shadow-md"
|
||||||
sidebarLinks.length + 1
|
v-if="showMenu"
|
||||||
}, minmax(0, 1fr))`,
|
ref="menu"
|
||||||
}"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="tab in sidebarLinks"
|
|
||||||
:key="tab.label"
|
|
||||||
:class="isVisible(tab) ? 'block' : 'hidden'"
|
|
||||||
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
|
|
||||||
@click="handleClick(tab)"
|
|
||||||
>
|
>
|
||||||
<component
|
<div
|
||||||
:is="icons[tab.icon]"
|
v-for="link in otherLinks"
|
||||||
class="h-6 w-6 stroke-1.5"
|
:key="link.label"
|
||||||
:class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']"
|
class="flex items-center space-x-2 cursor-pointer"
|
||||||
/>
|
@click="handleClick(link)"
|
||||||
</button>
|
>
|
||||||
<Popover
|
<component
|
||||||
trigger="hover"
|
:is="icons[link.icon]"
|
||||||
popoverClass="bottom-28 mx-2"
|
class="h-4 w-4 stroke-1.5 text-ink-gray-5"
|
||||||
placement="top-start"
|
/>
|
||||||
|
<div>{{ link.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fixed menu -->
|
||||||
|
<div
|
||||||
|
v-if="sidebarSettings.data"
|
||||||
|
class="fixed bottom-0 left-0 w-full flex items-center justify-around border-t border-outline-gray-2 bg-surface-white standalone:pb-4 z-10"
|
||||||
>
|
>
|
||||||
<template #target>
|
<button
|
||||||
|
v-for="tab in sidebarLinks"
|
||||||
|
:key="tab.label"
|
||||||
|
:class="isVisible(tab) ? 'block' : 'hidden'"
|
||||||
|
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
|
||||||
|
@click="handleClick(tab)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="icons[tab.icon]"
|
||||||
|
class="h-6 w-6 stroke-1.5"
|
||||||
|
:class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button @click="toggleMenu">
|
||||||
<component
|
<component
|
||||||
:is="icons['List']"
|
:is="icons['List']"
|
||||||
class="h-6 w-6 stroke-1.5 text-ink-gray-5"
|
class="h-6 w-6 stroke-1.5 text-ink-gray-5"
|
||||||
/>
|
/>
|
||||||
</template>
|
</button>
|
||||||
<template #body-main>
|
</div>
|
||||||
<div class="text-base p-5 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="link in otherLinks"
|
|
||||||
:key="link.label"
|
|
||||||
class="flex items-center space-x-2"
|
|
||||||
@click="handleClick(link)"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="icons[link.icon]"
|
|
||||||
class="h-4 w-4 stroke-1.5 text-ink-gray-5"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
{{ link.label }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -64,7 +59,6 @@ import { useRouter } from 'vue-router'
|
|||||||
import { watch, ref, onMounted } from 'vue'
|
import { watch, ref, onMounted } from 'vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import { Popover } from 'frappe-ui'
|
|
||||||
import * as icons from 'lucide-vue-next'
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|
||||||
const { logout, user, sidebarSettings } = sessionStore()
|
const { logout, user, sidebarSettings } = sessionStore()
|
||||||
@@ -73,26 +67,47 @@ const router = useRouter()
|
|||||||
let { userResource } = usersStore()
|
let { userResource } = usersStore()
|
||||||
const sidebarLinks = ref(getSidebarLinks())
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
const otherLinks = ref([])
|
const otherLinks = ref([])
|
||||||
|
const showMenu = ref(false)
|
||||||
|
const menu = ref(null)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
sidebarSettings.reload(
|
sidebarSettings.reload(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
Object.keys(data).forEach((key) => {
|
filterLinksToShow(data)
|
||||||
if (!parseInt(data[key])) {
|
|
||||||
sidebarLinks.value = sidebarLinks.value.filter(
|
|
||||||
(link) => link.label.toLowerCase().split(' ').join('_') !== key
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
addOtherLinks()
|
addOtherLinks()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleOutsideClick = (e) => {
|
||||||
|
if (menu.value && !menu.value.contains(e.target)) {
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showMenu, (val) => {
|
||||||
|
if (val) {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', handleOutsideClick)
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', handleOutsideClick)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterLinksToShow = (data) => {
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
if (!parseInt(data[key])) {
|
||||||
|
sidebarLinks.value = sidebarLinks.value.filter(
|
||||||
|
(link) => link.label.toLowerCase().split(' ').join('_') !== key
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const addOtherLinks = () => {
|
const addOtherLinks = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
otherLinks.value.push({
|
otherLinks.value.push({
|
||||||
@@ -122,6 +137,7 @@ watch(userResource, () => {
|
|||||||
(userResource.data.is_moderator || userResource.data.is_instructor)
|
(userResource.data.is_moderator || userResource.data.is_instructor)
|
||||||
) {
|
) {
|
||||||
addQuizzes()
|
addQuizzes()
|
||||||
|
addAssignments()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -133,6 +149,14 @@ const addQuizzes = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addAssignments = () => {
|
||||||
|
otherLinks.value.push({
|
||||||
|
label: 'Assignments',
|
||||||
|
icon: 'Pencil',
|
||||||
|
to: 'Assignments',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let isActive = (tab) => {
|
let isActive = (tab) => {
|
||||||
return tab.activeFor?.includes(router.currentRoute.value.name)
|
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||||
}
|
}
|
||||||
@@ -158,4 +182,8 @@ const isVisible = (tab) => {
|
|||||||
else if (tab.label == 'Log out') return isLoggedIn
|
else if (tab.label == 'Log out') return isLoggedIn
|
||||||
else return true
|
else return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
showMenu.value = !showMenu.value
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="p-5 text-base max-h-[75vh] overflow-y-auto">
|
<div class="p-5 text-base">
|
||||||
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
|
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
|
||||||
{{
|
{{
|
||||||
assignmentID === 'new'
|
assignmentID === 'new'
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
: __('Edit Assignment')
|
: __('Edit Assignment')
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="assignment.title"
|
v-model="assignment.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ import {
|
|||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, watch } from 'vue'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { getFileSize, escapeHTML } from '@/utils'
|
import { getFileSize } from '@/utils'
|
||||||
|
|
||||||
const reloadProfile = defineModel('reloadProfile')
|
const reloadProfile = defineModel('reloadProfile')
|
||||||
|
|
||||||
@@ -132,7 +132,6 @@ const imageResource = createResource({
|
|||||||
const updateProfile = createResource({
|
const updateProfile = createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
profile.bio = escapeHTML(profile.bio)
|
|
||||||
return {
|
return {
|
||||||
doctype: 'User',
|
doctype: 'User',
|
||||||
name: props.profile.data.name,
|
name: props.profile.data.name,
|
||||||
|
|||||||
192
frontend/src/components/Modals/EmailTemplateModal.vue
Normal file
192
frontend/src/components/Modals/EmailTemplateModal.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title:
|
||||||
|
templateID == 'new'
|
||||||
|
? __('New Email Template')
|
||||||
|
: __('Edit Email Template'),
|
||||||
|
size: 'lg',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: ({ close }) => {
|
||||||
|
saveTemplate(close)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FormControl
|
||||||
|
:label="__('Name')"
|
||||||
|
v-model="template.name"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
:placeholder="__('Batch Enrollment Confirmation')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Subject')"
|
||||||
|
v-model="template.subject"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
:placeholder="__('Your enrollment in {{ batch_name }} is confirmed')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Use HTML')"
|
||||||
|
v-model="template.use_html"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-if="template.use_html"
|
||||||
|
:label="__('Content')"
|
||||||
|
v-model="template.response_html"
|
||||||
|
type="textarea"
|
||||||
|
:required="true"
|
||||||
|
:rows="10"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'<p>Dear {{ member_name }},</p>\n\n<p>You have been enrolled in our upcoming batch {{ batch_name }}.</p>\n\n<p>Thanks,</p>\n<p>Frappe Learning</p>'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div v-else>
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
|
{{ __('Content') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="template.response"
|
||||||
|
@change="(val) => (template.response = val)"
|
||||||
|
:editable="true"
|
||||||
|
:fixedMenu="true"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
templateID: {
|
||||||
|
type: String,
|
||||||
|
default: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const emailTemplates = defineModel('emailTemplates')
|
||||||
|
const template = reactive({
|
||||||
|
name: '',
|
||||||
|
subject: '',
|
||||||
|
use_html: false,
|
||||||
|
response: '',
|
||||||
|
response_html: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveTemplate = (close) => {
|
||||||
|
if (props.templateID == 'new') {
|
||||||
|
createNewTemplate(close)
|
||||||
|
} else {
|
||||||
|
updateTemplate(close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewTemplate = (close) => {
|
||||||
|
emailTemplates.value.insert.submit(
|
||||||
|
{
|
||||||
|
__newname: template.name,
|
||||||
|
...template,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
emailTemplates.value.reload()
|
||||||
|
refreshForm(close)
|
||||||
|
toast.success(__('Email Template created successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
refreshForm(close)
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error creating email template')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTemplate = async (close) => {
|
||||||
|
if (props.templateID != template.name) {
|
||||||
|
await renameDoc()
|
||||||
|
}
|
||||||
|
setValue(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setValue = (close) => {
|
||||||
|
emailTemplates.value.setValue.submit(
|
||||||
|
{
|
||||||
|
...template,
|
||||||
|
name: template.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
emailTemplates.value.reload()
|
||||||
|
refreshForm(close)
|
||||||
|
toast.success(__('Email Template updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
refreshForm(close)
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error updating email template')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameDoc = async () => {
|
||||||
|
await call('frappe.client.rename_doc', {
|
||||||
|
doctype: 'Email Template',
|
||||||
|
old_name: props.templateID,
|
||||||
|
new_name: template.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.templateID,
|
||||||
|
(val) => {
|
||||||
|
if (val !== 'new') {
|
||||||
|
emailTemplates.value?.data.forEach((row) => {
|
||||||
|
if (row.name === val) {
|
||||||
|
template.name = row.name
|
||||||
|
template.subject = row.subject
|
||||||
|
template.use_html = row.use_html
|
||||||
|
template.response = row.response
|
||||||
|
template.response_html = row.response_html
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const refreshForm = (close) => {
|
||||||
|
close()
|
||||||
|
template.name = ''
|
||||||
|
template.subject = ''
|
||||||
|
template.use_html = false
|
||||||
|
template.response = ''
|
||||||
|
template.response_html = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createResource, Select, FormControl } from 'frappe-ui'
|
import { Dialog, createResource, Select, FormControl, toast } from 'frappe-ui'
|
||||||
import { reactive, watch, inject } from 'vue'
|
import { reactive, watch, inject } from 'vue'
|
||||||
import { formatTime } from '@/utils/'
|
import { formatTime } from '@/utils/'
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let evaluation = reactive({
|
const evaluation = reactive({
|
||||||
course: '',
|
course: '',
|
||||||
date: '',
|
date: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
@@ -139,22 +139,13 @@ function submitEvaluation(close) {
|
|||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
let message = err.messages?.[0] || err
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
let unavailabilityMessage
|
|
||||||
|
|
||||||
if (typeof message === 'string') {
|
|
||||||
unavailabilityMessage = message?.includes('unavailable')
|
|
||||||
} else {
|
|
||||||
unavailabilityMessage = false
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.warning(__('Evaluator is unavailable'))
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCourses = () => {
|
const getCourses = () => {
|
||||||
let courses = []
|
const courses = []
|
||||||
for (const course of props.courses) {
|
for (const course of props.courses) {
|
||||||
if (course.evaluator) {
|
if (course.evaluator) {
|
||||||
courses.push({
|
courses.push({
|
||||||
@@ -164,7 +155,7 @@ const getCourses = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (courses.length == 1) {
|
if (courses.length === 1) {
|
||||||
evaluation.course = courses[0].value
|
evaluation.course = courses[0].value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2">
|
<Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-l w-1/2">
|
||||||
<template #default="{ tab }">
|
<template #tab-panel="{ tab }">
|
||||||
<div
|
<div
|
||||||
v-if="tab.label == 'Evaluation'"
|
v-if="tab.label == 'Evaluation'"
|
||||||
class="flex flex-col space-y-4 p-5"
|
class="flex flex-col space-y-4 p-5"
|
||||||
|
|||||||
115
frontend/src/components/Modals/FeedbackModal.vue
Normal file
115
frontend/src/components/Modals/FeedbackModal.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: '4xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-5 min-h-[300px]">
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('Training Feedback') }}
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
:columns="feedbackColumns"
|
||||||
|
:rows="feedbackList"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
rowHeight: 'h-16',
|
||||||
|
selectable: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
></ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow
|
||||||
|
:row="row"
|
||||||
|
v-for="row in feedbackList"
|
||||||
|
class="group feedback-list"
|
||||||
|
>
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem
|
||||||
|
:item="row[column.key]"
|
||||||
|
:align="column.align"
|
||||||
|
class="text-sm"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key == 'member_name'">
|
||||||
|
<Avatar
|
||||||
|
class="flex"
|
||||||
|
:image="row['member_image']"
|
||||||
|
:label="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="ratingKeys.includes(column.key)">
|
||||||
|
<Rating v-model="row[column.key]" :readonly="true" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="leading-5">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
ListView,
|
||||||
|
Avatar,
|
||||||
|
ListHeader,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
Rating,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { reactive, computed } from 'vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const ratingKeys = ['content', 'instructors', 'value']
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
feedbackList: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const feedbackColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Member',
|
||||||
|
key: 'member_name',
|
||||||
|
width: '10rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Feedback',
|
||||||
|
key: 'feedback',
|
||||||
|
width: '15rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Content',
|
||||||
|
key: 'content',
|
||||||
|
width: '9rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Instructors',
|
||||||
|
key: 'instructors',
|
||||||
|
width: '9rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Value',
|
||||||
|
key: 'value',
|
||||||
|
width: '9rem',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
91
frontend/src/components/Modals/LiveClassAttendance.vue
Normal file
91
frontend/src/components/Modals/LiveClassAttendance.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Attendance for Class - {0}').format(live_class?.title),
|
||||||
|
size: 'xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div
|
||||||
|
v-for="participant in participants.data"
|
||||||
|
@click="redirectToProfile(participant.member_username)"
|
||||||
|
class="cursor-pointer text-base w-fit"
|
||||||
|
>
|
||||||
|
<Tooltip placement="right">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Avatar
|
||||||
|
:image="participant.member_image"
|
||||||
|
:label="participant.member_name"
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ participant.member_name }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ participant.member }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #body>
|
||||||
|
<div
|
||||||
|
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-p-xs text-ink-white leading-5 shadow-xl"
|
||||||
|
>
|
||||||
|
{{ dayjs(participant.joined_at).format('HH:mm a') }} -
|
||||||
|
{{ dayjs(participant.left_at).format('HH:mm a') }}
|
||||||
|
<br />
|
||||||
|
{{ __('attended for') }} {{ participant.duration }}
|
||||||
|
{{ __('minutes') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Avatar, createListResource, Dialog, Tooltip } from 'frappe-ui'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const router = useRouter()
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
|
||||||
|
interface LiveClass {
|
||||||
|
name: String
|
||||||
|
title: String
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
live_class: LiveClass | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const participants = createListResource({
|
||||||
|
doctype: 'LMS Live Class Participant',
|
||||||
|
filter: {
|
||||||
|
live_class: props.live_class?.name,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_image',
|
||||||
|
'member_username',
|
||||||
|
'joined_at',
|
||||||
|
'left_at',
|
||||||
|
'duration',
|
||||||
|
],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const redirectToProfile = (username: string) => {
|
||||||
|
router.push({
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
{
|
{
|
||||||
label: 'Submit',
|
label: 'Submit',
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
onClick: (close) => submitLiveClass(close),
|
onClick: ({ close }) => submitLiveClass(close),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}"
|
}"
|
||||||
@@ -16,14 +16,29 @@
|
|||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div class="space-y-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="text"
|
type="text"
|
||||||
v-model="liveClass.title"
|
v-model="liveClass.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
class="mb-4"
|
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="liveClass.date"
|
||||||
|
type="date"
|
||||||
|
:label="__('Date')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<Tooltip :text="__('Duration of the live class in minutes')">
|
||||||
|
<FormControl
|
||||||
|
type="number"
|
||||||
|
v-model="liveClass.duration"
|
||||||
|
:label="__('Duration')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
:text="
|
:text="
|
||||||
__(
|
__(
|
||||||
@@ -35,7 +50,6 @@
|
|||||||
v-model="liveClass.time"
|
v-model="liveClass.time"
|
||||||
type="time"
|
type="time"
|
||||||
:label="__('Time')"
|
:label="__('Time')"
|
||||||
class="mb-4"
|
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -52,24 +66,6 @@
|
|||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FormControl
|
|
||||||
v-model="liveClass.date"
|
|
||||||
type="date"
|
|
||||||
class="mb-4"
|
|
||||||
:label="__('Date')"
|
|
||||||
:required="true"
|
|
||||||
/>
|
|
||||||
<Tooltip :text="__('Duration of the live class in minutes')">
|
|
||||||
<FormControl
|
|
||||||
type="number"
|
|
||||||
v-model="liveClass.duration"
|
|
||||||
:label="__('Duration')"
|
|
||||||
class="mb-4"
|
|
||||||
:required="true"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="liveClass.auto_recording"
|
v-model="liveClass.auto_recording"
|
||||||
type="select"
|
type="select"
|
||||||
@@ -107,7 +103,11 @@ const dayjs = inject('$dayjs')
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
required: true,
|
||||||
|
},
|
||||||
|
zoomAccount: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,6 +159,7 @@ const createLiveClass = createResource({
|
|||||||
return {
|
return {
|
||||||
doctype: 'LMS Live Class',
|
doctype: 'LMS Live Class',
|
||||||
batch_name: values.batch,
|
batch_name: values.batch,
|
||||||
|
zoom_account: props.zoomAccount,
|
||||||
...values,
|
...values,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -167,39 +168,11 @@ const createLiveClass = createResource({
|
|||||||
const submitLiveClass = (close) => {
|
const submitLiveClass = (close) => {
|
||||||
return createLiveClass.submit(liveClass, {
|
return createLiveClass.submit(liveClass, {
|
||||||
validate() {
|
validate() {
|
||||||
if (!liveClass.title) {
|
validateFormFields()
|
||||||
return __('Please enter a title.')
|
|
||||||
}
|
|
||||||
if (!liveClass.date) {
|
|
||||||
return __('Please select a date.')
|
|
||||||
}
|
|
||||||
if (!liveClass.time) {
|
|
||||||
return __('Please select a time.')
|
|
||||||
}
|
|
||||||
if (!liveClass.timezone) {
|
|
||||||
return __('Please select a timezone.')
|
|
||||||
}
|
|
||||||
if (!valideTime()) {
|
|
||||||
return __('Please enter a valid time in the format HH:mm.')
|
|
||||||
}
|
|
||||||
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
|
|
||||||
liveClass.timezone,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
liveClassDateTime.isSameOrBefore(
|
|
||||||
dayjs().tz(liveClass.timezone, false),
|
|
||||||
'minute'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return __('Please select a future date and time.')
|
|
||||||
}
|
|
||||||
if (!liveClass.duration) {
|
|
||||||
return __('Please select a duration.')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
liveClasses.value.reload()
|
liveClasses.value.reload()
|
||||||
|
refreshForm()
|
||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
@@ -208,6 +181,39 @@ const submitLiveClass = (close) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validateFormFields = () => {
|
||||||
|
if (!liveClass.title) {
|
||||||
|
return __('Please enter a title.')
|
||||||
|
}
|
||||||
|
if (!liveClass.date) {
|
||||||
|
return __('Please select a date.')
|
||||||
|
}
|
||||||
|
if (!liveClass.time) {
|
||||||
|
return __('Please select a time.')
|
||||||
|
}
|
||||||
|
if (!liveClass.timezone) {
|
||||||
|
return __('Please select a timezone.')
|
||||||
|
}
|
||||||
|
if (!valideTime()) {
|
||||||
|
return __('Please enter a valid time in the format HH:mm.')
|
||||||
|
}
|
||||||
|
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
|
||||||
|
liveClass.timezone,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
liveClassDateTime.isSameOrBefore(
|
||||||
|
dayjs().tz(liveClass.timezone, false),
|
||||||
|
'minute'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return __('Please select a future date and time.')
|
||||||
|
}
|
||||||
|
if (!liveClass.duration) {
|
||||||
|
return __('Please select a duration.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const valideTime = () => {
|
const valideTime = () => {
|
||||||
let time = liveClass.time.split(':')
|
let time = liveClass.time.split(':')
|
||||||
if (time.length != 2) {
|
if (time.length != 2) {
|
||||||
@@ -221,4 +227,14 @@ const valideTime = () => {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshForm = () => {
|
||||||
|
liveClass.title = ''
|
||||||
|
liveClass.description = ''
|
||||||
|
liveClass.date = ''
|
||||||
|
liveClass.time = ''
|
||||||
|
liveClass.duration = ''
|
||||||
|
liveClass.timezone = getUserTimezone()
|
||||||
|
liveClass.auto_recording = 'No Recording'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
225
frontend/src/components/Modals/QuizInVideo.vue
Normal file
225
frontend/src/components/Modals/QuizInVideo.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Add quiz to this video'),
|
||||||
|
size: '2xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="flex items-end gap-4">
|
||||||
|
<FormControl
|
||||||
|
:label="__('Time in Video')"
|
||||||
|
v-model="quiz.time"
|
||||||
|
type="text"
|
||||||
|
placeholder="2:15"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-model="quiz.quiz"
|
||||||
|
:label="__('Quiz')"
|
||||||
|
doctype="LMS Quiz"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button @click="addQuiz()" variant="solid">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="w-4 h-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 mb-5">
|
||||||
|
<div class="font-medium mb-4">
|
||||||
|
{{ __('Quizzes in this video') }}
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
v-if="allQuizzes.length"
|
||||||
|
:columns="columns"
|
||||||
|
:rows="allQuizzes"
|
||||||
|
row-key="quiz"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in columns">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5 ml-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in allQuizzes">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem
|
||||||
|
:item="row[column.key as keyof Quiz]"
|
||||||
|
:align="column.align"
|
||||||
|
>
|
||||||
|
<div v-if="column.key == 'time'" class="leading-5 text-sm">
|
||||||
|
{{ formatTimestamp(row[column.key as keyof Quiz]) }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="leading-5 text-sm">
|
||||||
|
{{ row[column.key as keyof Quiz] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeQuiz(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
|
||||||
|
<div v-else class="text-ink-gray-5 italic text-xs">
|
||||||
|
{{ __('No quizzes added yet.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { formatTimestamp } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
type Quiz = {
|
||||||
|
time: string
|
||||||
|
quiz: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const allQuizzes = ref<Quiz[]>([])
|
||||||
|
const quiz = reactive<Quiz>({
|
||||||
|
time: '',
|
||||||
|
quiz: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
quizzes: {
|
||||||
|
type: Array as () => Quiz[],
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
saveQuizzes: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addQuiz = () => {
|
||||||
|
quiz.time = `${getTimeInSeconds()}`
|
||||||
|
if (!isTimeValid() || !isFormComplete()) return
|
||||||
|
|
||||||
|
allQuizzes.value.push({
|
||||||
|
time: quiz.time,
|
||||||
|
quiz: quiz.quiz,
|
||||||
|
})
|
||||||
|
|
||||||
|
props.saveQuizzes(allQuizzes.value)
|
||||||
|
|
||||||
|
quiz.time = ''
|
||||||
|
quiz.quiz = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimeInSeconds = () => {
|
||||||
|
if (quiz.time && !quiz.time.includes(':')) {
|
||||||
|
quiz.time = `${quiz.time}:00`
|
||||||
|
}
|
||||||
|
const timeParts = quiz.time.split(':')
|
||||||
|
const timeInSeconds = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1])
|
||||||
|
|
||||||
|
return timeInSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTimeValid = () => {
|
||||||
|
if (parseInt(quiz.time) > props.duration) {
|
||||||
|
toast.error(__('Time in video exceeds the total duration of the video.'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormComplete = () => {
|
||||||
|
if (!quiz.time) {
|
||||||
|
toast.error(__('Please enter a valid timestamp'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quiz.quiz) {
|
||||||
|
toast.error(__('Please select a quiz'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeQuiz = (selections: string, unselectAll: () => void) => {
|
||||||
|
Array.from(selections).forEach((selection) => {
|
||||||
|
const index = allQuizzes.value.findIndex((q) => q.quiz === selection)
|
||||||
|
if (index !== -1) {
|
||||||
|
allQuizzes.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
unselectAll()
|
||||||
|
})
|
||||||
|
props.saveQuizzes(allQuizzes.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.quizzes,
|
||||||
|
(newQuizzes) => {
|
||||||
|
allQuizzes.value = newQuizzes
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'quiz',
|
||||||
|
label: __('Quiz'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
label: __('Time in Video (minutes)'),
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -15,26 +15,20 @@
|
|||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<Rating v-model="review.rating" :label="__('Rating')" />
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
<FormControl
|
||||||
{{ __('Rating') }}
|
:label="__('Review')"
|
||||||
</div>
|
type="textarea"
|
||||||
<Rating v-model="review.rating" />
|
v-model="review.review"
|
||||||
</div>
|
:rows="5"
|
||||||
<div>
|
/>
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
|
||||||
{{ __('Review') }}
|
|
||||||
</div>
|
|
||||||
<Textarea type="text" size="md" rows="5" v-model="review.review" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, Textarea, createResource, toast } from 'frappe-ui'
|
import { Dialog, FormControl, createResource, toast, Rating } from 'frappe-ui'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import Rating from '@/components/Controls/Rating.vue'
|
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const reviews = defineModel('reloadReviews')
|
const reviews = defineModel('reloadReviews')
|
||||||
|
|||||||
206
frontend/src/components/Modals/ZoomAccountModal.vue
Normal file
206
frontend/src/components/Modals/ZoomAccountModal.vue
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title:
|
||||||
|
accountID === 'new' ? __('New Zoom Account') : __('Edit Zoom Account'),
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: ({ close }) => {
|
||||||
|
saveAccount(close)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="mb-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="account.enabled"
|
||||||
|
:label="__('Enabled')"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="account.name"
|
||||||
|
:label="__('Account Name')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="account.client_id"
|
||||||
|
:label="__('Client ID')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-model="account.member"
|
||||||
|
:label="__('Member')"
|
||||||
|
doctype="Course Evaluator"
|
||||||
|
:onCreate="(value, close) => openSettings('Members', close)"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="account.client_secret"
|
||||||
|
:label="__('Client Secret')"
|
||||||
|
type="password"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="account.account_id"
|
||||||
|
:label="__('Account ID')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||||
|
import { inject, reactive, watch } from 'vue'
|
||||||
|
import { User } from '@/components/Settings/types'
|
||||||
|
import { openSettings, cleanError } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
interface ZoomAccount {
|
||||||
|
name: string
|
||||||
|
account_name: string
|
||||||
|
enabled: boolean
|
||||||
|
member: string
|
||||||
|
account_id: string
|
||||||
|
client_id: string
|
||||||
|
client_secret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZoomAccounts {
|
||||||
|
data: ZoomAccount[]
|
||||||
|
reload: () => void
|
||||||
|
insert: {
|
||||||
|
submit: (
|
||||||
|
data: ZoomAccount,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = defineModel('show')
|
||||||
|
const user = inject<User | null>('$user')
|
||||||
|
const zoomAccounts = defineModel<ZoomAccounts>('zoomAccounts')
|
||||||
|
|
||||||
|
const account = reactive({
|
||||||
|
name: '',
|
||||||
|
enabled: false,
|
||||||
|
member: user?.data?.name || '',
|
||||||
|
account_id: '',
|
||||||
|
client_id: '',
|
||||||
|
client_secret: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
accountID: {
|
||||||
|
type: String,
|
||||||
|
default: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.accountID,
|
||||||
|
(val) => {
|
||||||
|
if (val != 'new') {
|
||||||
|
zoomAccounts.value?.data.forEach((acc) => {
|
||||||
|
if (acc.name === val) {
|
||||||
|
account.name = acc.name
|
||||||
|
account.enabled = acc.enabled || false
|
||||||
|
account.member = acc.member
|
||||||
|
account.account_id = acc.account_id
|
||||||
|
account.client_id = acc.client_id
|
||||||
|
account.client_secret = acc.client_secret
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(show, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
account.name = ''
|
||||||
|
account.enabled = false
|
||||||
|
account.member = user?.data?.name || ''
|
||||||
|
account.account_id = ''
|
||||||
|
account.client_id = ''
|
||||||
|
account.client_secret = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveAccount = (close) => {
|
||||||
|
if (props.accountID == 'new') {
|
||||||
|
createAccount(close)
|
||||||
|
} else {
|
||||||
|
updateAccount(close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAccount = (close) => {
|
||||||
|
zoomAccounts.value?.insert.submit(
|
||||||
|
{
|
||||||
|
account_name: account.name,
|
||||||
|
...account,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
zoomAccounts.value?.reload()
|
||||||
|
close()
|
||||||
|
toast.success(__('Zoom Account created successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
close()
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error creating Zoom Account')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAccount = async (close) => {
|
||||||
|
if (props.accountID != account.name) {
|
||||||
|
await renameDoc()
|
||||||
|
}
|
||||||
|
setValue(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameDoc = async () => {
|
||||||
|
await call('frappe.client.rename_doc', {
|
||||||
|
doctype: 'LMS Zoom Settings',
|
||||||
|
old_name: props.accountID,
|
||||||
|
new_name: account.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setValue = (close) => {
|
||||||
|
zoomAccounts.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
...account,
|
||||||
|
name: account.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
zoomAccounts.value?.reload()
|
||||||
|
close()
|
||||||
|
toast.success(__('Zoom Account updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
close()
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error updating Zoom Account')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="quiz.data">
|
<div v-if="quiz.data">
|
||||||
<div
|
<div
|
||||||
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3"
|
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3 leading-5"
|
||||||
>
|
>
|
||||||
|
<div v-if="inVideo">
|
||||||
|
{{ __('You will have to complete the quiz to continue the video') }}
|
||||||
|
</div>
|
||||||
<div class="leading-5">
|
<div class="leading-5">
|
||||||
{{
|
{{
|
||||||
__('This quiz consists of {0} questions.').format(questions.length)
|
__('This quiz consists of {0} questions.').format(questions.length)
|
||||||
@@ -55,19 +58,30 @@
|
|||||||
<div class="font-semibold text-lg text-ink-gray-9">
|
<div class="font-semibold text-lg text-ink-gray-9">
|
||||||
{{ quiz.data.title }}
|
{{ quiz.data.title }}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div class="flex items-center justify-center space-x-2 mt-4">
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
!quiz.data.max_attempts ||
|
||||||
|
attempts.data?.length < quiz.data.max_attempts
|
||||||
|
"
|
||||||
|
variant="solid"
|
||||||
|
@click="startQuiz"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ inVideo ? __('Start the Quiz') : __('Start') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button v-if="inVideo" @click="props.backToVideo()">
|
||||||
|
{{ __('Resume Video') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
!quiz.data.max_attempts ||
|
quiz.data.max_attempts &&
|
||||||
attempts.data?.length < quiz.data.max_attempts
|
attempts.data?.length >= quiz.data.max_attempts
|
||||||
"
|
"
|
||||||
@click="startQuiz"
|
class="leading-5 text-ink-gray-7"
|
||||||
class="mt-2"
|
|
||||||
>
|
>
|
||||||
<span>
|
|
||||||
{{ __('Start') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<div v-else class="leading-5 text-ink-gray-7">
|
|
||||||
{{
|
{{
|
||||||
__(
|
__(
|
||||||
'You have already exceeded the maximum number of attempts allowed for this quiz.'
|
'You have already exceeded the maximum number of attempts allowed for this quiz.'
|
||||||
@@ -247,18 +261,23 @@
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div class="space-x-2">
|
||||||
@click="resetQuiz()"
|
<Button
|
||||||
class="mt-2"
|
@click="resetQuiz()"
|
||||||
v-if="
|
class="mt-2"
|
||||||
!quiz.data.max_attempts ||
|
v-if="
|
||||||
attempts?.data.length < quiz.data.max_attempts
|
!quiz.data.max_attempts ||
|
||||||
"
|
attempts?.data.length < quiz.data.max_attempts
|
||||||
>
|
"
|
||||||
<span>
|
>
|
||||||
{{ __('Try Again') }}
|
<span>
|
||||||
</span>
|
{{ __('Try Again') }}
|
||||||
</Button>
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button v-if="inVideo" @click="props.backToVideo()">
|
||||||
|
{{ __('Resume Video') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@@ -308,13 +327,20 @@ let questions = reactive([])
|
|||||||
const possibleAnswer = ref(null)
|
const possibleAnswer = ref(null)
|
||||||
const timer = ref(0)
|
const timer = ref(0)
|
||||||
let timerInterval = null
|
let timerInterval = null
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quizName: {
|
quizName: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
inVideo: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
backToVideo: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const quiz = createResource({
|
const quiz = createResource({
|
||||||
@@ -611,11 +637,15 @@ const getInstructions = (question) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const markLessonProgress = () => {
|
const markLessonProgress = () => {
|
||||||
if (router.currentRoute.value.name == 'Lesson') {
|
let pathname = window.location.pathname.split('/')
|
||||||
|
if (pathname[2] != 'courses') return
|
||||||
|
let lessonIndex = pathname.pop().split('-')
|
||||||
|
|
||||||
|
if (lessonIndex.length == 2) {
|
||||||
call('lms.lms.api.mark_lesson_progress', {
|
call('lms.lms.api.mark_lesson_progress', {
|
||||||
course: router.currentRoute.value.params.courseName,
|
course: pathname[3],
|
||||||
chapter_number: router.currentRoute.value.params.chapterNumber,
|
chapter_number: lessonIndex[0],
|
||||||
lesson_number: router.currentRoute.value.params.lessonNumber,
|
lesson_number: lessonIndex[1],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col justify-between min-h-0">
|
<div class="flex flex-col justify-between h-full">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="font-semibold mb-1 text-ink-gray-9">
|
<div class="font-semibold mb-1 text-ink-gray-9">
|
||||||
@@ -18,17 +18,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto">
|
<div class="overflow-y-auto">
|
||||||
<SettingFields :fields="fields" :data="data.data" />
|
<SettingFields :fields="fields" :data="data.data" />
|
||||||
<div class="flex flex-row-reverse mt-auto">
|
</div>
|
||||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
<div class="flex flex-row-reverse mt-auto">
|
||||||
{{ __('Update') }}
|
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||||
</Button>
|
{{ __('Update') }}
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Button, Badge } from 'frappe-ui'
|
import { createResource, Button, Badge } from 'frappe-ui'
|
||||||
import SettingFields from '@/components/SettingFields.vue'
|
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||||
import { watch, ref } from 'vue'
|
import { watch, ref } from 'vue'
|
||||||
|
|
||||||
const isDirty = ref(false)
|
const isDirty = ref(false)
|
||||||
216
frontend/src/components/Settings/Categories.vue
Normal file
216
frontend/src/components/Settings/Categories.vue
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-0 text-base">
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-ink-gray-5">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-5">
|
||||||
|
<div
|
||||||
|
class="flex items-center space-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
|
||||||
|
v-if="saving"
|
||||||
|
>
|
||||||
|
<LoadingIndicator class="size-2" />
|
||||||
|
<span class="text-xs">
|
||||||
|
{{ __('saving...') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button @click="() => showCategoryForm()">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
||||||
|
<X v-else class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ showForm ? __('Close') : __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showForm"
|
||||||
|
class="flex items-center justify-between my-4 space-x-2"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
ref="categoryInput"
|
||||||
|
v-model="category"
|
||||||
|
:placeholder="__('Category Name')"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button @click="addCategory()" variant="subtle">
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-scroll">
|
||||||
|
<div class="divide-y space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(cat, index) in categories.data"
|
||||||
|
:key="cat.name"
|
||||||
|
class="pt-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="editing?.name !== cat.name"
|
||||||
|
class="flex items-center justify-between group text-sm"
|
||||||
|
>
|
||||||
|
<div @dblclick="allowEdit(cat, index)">
|
||||||
|
{{ cat.category }}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
theme="red"
|
||||||
|
class="invisible group-hover:visible"
|
||||||
|
@click="deleteCategory(cat.name)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Trash2 class="size-4 stroke-1.5 text-ink-red-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-else
|
||||||
|
:ref="(el) => (editInputRef[index] = el)"
|
||||||
|
v-model="editedValue"
|
||||||
|
type="text"
|
||||||
|
class="w-full"
|
||||||
|
@keyup.enter="saveChanges(cat.name, editedValue)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
LoadingIndicator,
|
||||||
|
createListResource,
|
||||||
|
createResource,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { Plus, Trash2, X } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const category = ref(null)
|
||||||
|
const categoryInput = ref(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const editing = ref(null)
|
||||||
|
const editedValue = ref('')
|
||||||
|
const editInputRef = ref([])
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const categories = createListResource({
|
||||||
|
doctype: 'LMS Category',
|
||||||
|
fields: ['name', 'category'],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const addCategory = () => {
|
||||||
|
categories.insert.submit(
|
||||||
|
{
|
||||||
|
category: category.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
categories.reload()
|
||||||
|
category.value = null
|
||||||
|
showForm.value = false
|
||||||
|
toast.success(__('Category added successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(__(cleanError(err.messages[0]) || 'Unable to add category'))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCategoryForm = () => {
|
||||||
|
showForm.value = !showForm.value
|
||||||
|
setTimeout(() => {
|
||||||
|
categoryInput.value.$el.querySelector('input').focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCategory = createResource({
|
||||||
|
url: 'frappe.client.rename_doc',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Category',
|
||||||
|
old_name: values.name,
|
||||||
|
new_name: values.category,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = (name, value) => {
|
||||||
|
saving.value = true
|
||||||
|
updateCategory.submit(
|
||||||
|
{
|
||||||
|
name: name,
|
||||||
|
category: value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
saving.value = false
|
||||||
|
categories.reload()
|
||||||
|
editing.value = null
|
||||||
|
editedValue.value = ''
|
||||||
|
toast.success(__('Category updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
saving.value = false
|
||||||
|
editing.value = null
|
||||||
|
editedValue.value = ''
|
||||||
|
toast.error(
|
||||||
|
__(cleanError(err.messages[0]) || 'Unable to update category')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCategory = (name) => {
|
||||||
|
saving.value = true
|
||||||
|
categories.delete.submit(name, {
|
||||||
|
onSuccess() {
|
||||||
|
saving.value = false
|
||||||
|
categories.reload()
|
||||||
|
toast.success(__('Category deleted successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
saving.value = false
|
||||||
|
toast.error(
|
||||||
|
__(cleanError(err.messages[0]) || 'Unable to delete category')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveChanges = (name, value) => {
|
||||||
|
saving.value = true
|
||||||
|
update(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowEdit = (cat, index) => {
|
||||||
|
editing.value = cat
|
||||||
|
editedValue.value = cat.category
|
||||||
|
setTimeout(() => {
|
||||||
|
editInputRef.value[index].$el.querySelector('input').focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
160
frontend/src/components/Settings/EmailTemplates.vue
Normal file
160
frontend/src/components/Settings/EmailTemplates.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-0 text-base">
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<!-- <div class="text-xs text-ink-gray-5">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-5">
|
||||||
|
<Button @click="openTemplateForm('new')">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="emailTemplates.data?.length" class="overflow-y-scroll">
|
||||||
|
<ListView
|
||||||
|
:columns="columns"
|
||||||
|
:rows="emailTemplates.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
onRowClick: (row) => {
|
||||||
|
openTemplateForm(row.name)
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in columns">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5 ml-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in emailTemplates.data">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<div class="leading-5 text-sm">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeTemplate(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EmailTemplateModal
|
||||||
|
v-model="showForm"
|
||||||
|
v-model:emailTemplates="emailTemplates"
|
||||||
|
:templateID="selectedTemplate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
call,
|
||||||
|
createListResource,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const readOnlyMode = window.read_only_mode
|
||||||
|
const selectedTemplate = ref(null)
|
||||||
|
|
||||||
|
const emailTemplates = createListResource({
|
||||||
|
doctype: 'Email Template',
|
||||||
|
fields: ['name', 'subject', 'use_html', 'response', 'response_html'],
|
||||||
|
auto: true,
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
cache: 'email-templates',
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeTemplate = (selections, unselectAll) => {
|
||||||
|
call('lms.lms.api.delete_documents', {
|
||||||
|
doctype: 'Email Template',
|
||||||
|
documents: Array.from(selections),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
emailTemplates.reload()
|
||||||
|
toast.success(__('Email Templates deleted successfully'))
|
||||||
|
unselectAll()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error deleting email templates')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTemplateForm = (templateID) => {
|
||||||
|
if (readOnlyMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedTemplate.value = templateID
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Name',
|
||||||
|
key: 'name',
|
||||||
|
width: '20rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Subject',
|
||||||
|
key: 'subject',
|
||||||
|
width: '25rem',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex min-h-0 flex-col text-base">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||||
@@ -33,31 +33,34 @@
|
|||||||
:placeholder="__('Email')"
|
:placeholder="__('Email')"
|
||||||
type="email"
|
type="email"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
@keydown.enter="addEvaluator"
|
||||||
/>
|
/>
|
||||||
<Button @click="addEvaluator()" variant="subtle">
|
<Button @click="addEvaluator()" variant="subtle">
|
||||||
{{ __('Add') }}
|
{{ __('Add') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divide-y">
|
<div class="overflow-y-scroll">
|
||||||
<div
|
<div class="divide-y">
|
||||||
v-for="evaluator in evaluators.data"
|
<div
|
||||||
@click="openProfile(evaluator.username)"
|
v-for="evaluator in evaluators.data"
|
||||||
class="cursor-pointer"
|
@click="openProfile(evaluator.username)"
|
||||||
>
|
class="cursor-pointer"
|
||||||
<div class="flex items-center justify-between py-3">
|
>
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center justify-between py-3">
|
||||||
<Avatar
|
<div class="flex items-center space-x-3">
|
||||||
:image="evaluator.user_image"
|
<Avatar
|
||||||
:label="evaluator.full_name"
|
:image="evaluator.user_image"
|
||||||
size="lg"
|
:label="evaluator.full_name"
|
||||||
/>
|
size="lg"
|
||||||
<div>
|
/>
|
||||||
<div class="text-base font-semibold text-ink-gray-9">
|
<div>
|
||||||
{{ evaluator.full_name }}
|
<div class="text-base font-semibold text-ink-gray-9">
|
||||||
</div>
|
{{ evaluator.full_name }}
|
||||||
<div class="text-xs text-ink-gray-5">
|
</div>
|
||||||
{{ evaluator.evaluator }}
|
<div class="text-xs text-ink-gray-5">
|
||||||
|
{{ evaluator.evaluator }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,23 +118,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { ref, watch, reactive, inject } from 'vue'
|
import { ref, watch, reactive, inject } from 'vue'
|
||||||
import { RefreshCw, Plus, X } from 'lucide-vue-next'
|
import { RefreshCw, Plus, X } from 'lucide-vue-next'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
|
import type { User } from '@/components/Settings/types'
|
||||||
interface User {
|
|
||||||
data: {
|
|
||||||
email: string
|
|
||||||
name: string
|
|
||||||
enabled: boolean
|
|
||||||
user_image: string
|
|
||||||
full_name: string
|
|
||||||
user_type: ['System User', 'Website User']
|
|
||||||
username: string
|
|
||||||
is_moderator: boolean
|
|
||||||
is_system_manager: boolean
|
|
||||||
is_evaluator: boolean
|
|
||||||
is_instructor: boolean
|
|
||||||
is_fc_site: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const show = defineModel('show')
|
const show = defineModel('show')
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
/> -->
|
/> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-scroll">
|
<div class="overflow-y-scroll">
|
||||||
<div class="flex space-x-4">
|
<div class="flex flex-col divide-y">
|
||||||
<SettingFields :fields="fields" :data="data.doc" class="w-1/2" />
|
<SettingFields :fields="fields" :data="data.doc" />
|
||||||
<SettingFields
|
<SettingFields
|
||||||
v-if="paymentGateway.data"
|
v-if="paymentGateway.data"
|
||||||
:fields="paymentGateway.data.fields"
|
:fields="paymentGateway.data.fields"
|
||||||
:data="paymentGateway.data.data"
|
:data="paymentGateway.data.data"
|
||||||
class="w-1/2"
|
class="pt-5 my-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import SettingFields from '@/components/SettingFields.vue'
|
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||||
import { createResource, Badge, Button } from 'frappe-ui'
|
import { createResource, Badge, Button } from 'frappe-ui'
|
||||||
import { watch, ref } from 'vue'
|
import { watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
@@ -60,9 +60,28 @@ const paymentGateway = createResource({
|
|||||||
payment_gateway: props.data.doc.payment_gateway,
|
payment_gateway: props.data.doc.payment_gateway,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
transform(data) {
|
||||||
|
arrangeFields(data.fields)
|
||||||
|
return data
|
||||||
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const arrangeFields = (fields) => {
|
||||||
|
fields = fields.sort((a, b) => {
|
||||||
|
if (a.type === 'Upload' && b.type !== 'Upload') {
|
||||||
|
return 1
|
||||||
|
} else if (a.type !== 'Upload' && b.type === 'Upload') {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
fields.splice(3, 0, {
|
||||||
|
type: 'Column Break',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const saveSettings = createResource({
|
const saveSettings = createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, Badge, toast } from 'frappe-ui'
|
import { Button, Badge, toast } from 'frappe-ui'
|
||||||
import SettingFields from '@/components/SettingFields.vue'
|
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
fields: {
|
fields: {
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<div v-for="(column, index) in columns" :key="index">
|
<div v-for="(column, index) in columns" :key="index">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col space-y-5"
|
class="flex flex-col space-y-5"
|
||||||
:class="columns.length > 1 ? 'w-72' : 'w-full'"
|
:class="columns.length > 1 ? 'w-[21rem]' : 'w-full'"
|
||||||
>
|
>
|
||||||
<div v-for="field in column">
|
<div v-for="field in column">
|
||||||
<Link
|
<Link
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
v-model="data[field.name]"
|
v-model="data[field.name]"
|
||||||
:doctype="field.doctype"
|
:doctype="field.doctype"
|
||||||
:label="__(field.label)"
|
:label="__(field.label)"
|
||||||
|
:description="__(field.description)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-else-if="field.type == 'Code'">
|
<div v-else-if="field.type == 'Code'">
|
||||||
@@ -54,11 +55,13 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="flex items-center text-sm space-x-2">
|
<div class="flex items-center text-sm space-x-2">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center rounded border border-outline-gray-modals bg-white w-[10rem] py-2"
|
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
|
||||||
|
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="data[field.name]?.file_url || data[field.name]"
|
:src="data[field.name]?.file_url || data[field.name]"
|
||||||
class="w-[80%] rounded"
|
class="rounded"
|
||||||
|
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-wrap">
|
<div class="flex flex-col flex-wrap">
|
||||||
@@ -100,6 +103,7 @@
|
|||||||
:rows="field.rows"
|
:rows="field.rows"
|
||||||
:options="field.options"
|
:options="field.options"
|
||||||
:description="field.description"
|
:description="field.description"
|
||||||
|
:class="columns.length > 1 ? 'w-full' : 'w-1/2'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,6 +51,16 @@
|
|||||||
:label="activeTab.label"
|
:label="activeTab.label"
|
||||||
:description="activeTab.description"
|
:description="activeTab.description"
|
||||||
/>
|
/>
|
||||||
|
<EmailTemplates
|
||||||
|
v-else-if="activeTab.label === 'Email Templates'"
|
||||||
|
:label="activeTab.label"
|
||||||
|
:description="activeTab.description"
|
||||||
|
/>
|
||||||
|
<ZoomSettings
|
||||||
|
v-else-if="activeTab.label === 'Zoom Accounts'"
|
||||||
|
:label="activeTab.label"
|
||||||
|
:description="activeTab.description"
|
||||||
|
/>
|
||||||
<PaymentSettings
|
<PaymentSettings
|
||||||
v-else-if="activeTab.label === 'Payment Gateway'"
|
v-else-if="activeTab.label === 'Payment Gateway'"
|
||||||
:label="activeTab.label"
|
:label="activeTab.label"
|
||||||
@@ -81,13 +91,15 @@
|
|||||||
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
|
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
import SettingDetails from '../SettingDetails.vue'
|
import SettingDetails from '@/components/Settings/SettingDetails.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import Members from '@/components/Members.vue'
|
import Members from '@/components/Settings/Members.vue'
|
||||||
import Evaluators from '@/components/Evaluators.vue'
|
import Evaluators from '@/components/Settings/Evaluators.vue'
|
||||||
import Categories from '@/components/Categories.vue'
|
import Categories from '@/components/Settings/Categories.vue'
|
||||||
import BrandSettings from '@/components/BrandSettings.vue'
|
import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
|
||||||
import PaymentSettings from '@/components/PaymentSettings.vue'
|
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||||
|
import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
|
||||||
|
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const doctype = ref('LMS Settings')
|
const doctype = ref('LMS Settings')
|
||||||
@@ -122,7 +134,7 @@ const tabsStructure = computed(() => {
|
|||||||
label: 'Enable Learning Paths',
|
label: 'Enable Learning Paths',
|
||||||
name: 'enable_learning_paths',
|
name: 'enable_learning_paths',
|
||||||
description:
|
description:
|
||||||
'This will enforce students to go through programs assigned to them in the correct order.',
|
'This will ensure students follow the assigned programs in order.',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -139,11 +151,26 @@ const tabsStructure = computed(() => {
|
|||||||
'If enabled, it sends google calendar invite to the student for evaluations.',
|
'If enabled, it sends google calendar invite to the student for evaluations.',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Batch Confirmation Email Template',
|
||||||
|
name: 'batch_confirmation_template',
|
||||||
|
doctype: 'Email Template',
|
||||||
|
type: 'Link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Certification Email Template',
|
||||||
|
name: 'certification_template',
|
||||||
|
doctype: 'Email Template',
|
||||||
|
type: 'Link',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Unsplash Access Key',
|
label: 'Unsplash Access Key',
|
||||||
name: 'unsplash_access_key',
|
name: 'unsplash_access_key',
|
||||||
description:
|
description:
|
||||||
'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. https://unsplash.com/documentation#getting-started.',
|
'Allows users to pick a profile cover image from Unsplash. https://unsplash.com/documentation#getting-started.',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -160,6 +187,12 @@ const tabsStructure = computed(() => {
|
|||||||
description:
|
description:
|
||||||
'Configure the payment gateway and other payment related settings',
|
'Configure the payment gateway and other payment related settings',
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Default Currency',
|
||||||
|
name: 'default_currency',
|
||||||
|
type: 'Link',
|
||||||
|
doctype: 'Currency',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Payment Gateway',
|
label: 'Payment Gateway',
|
||||||
name: 'payment_gateway',
|
name: 'payment_gateway',
|
||||||
@@ -167,10 +200,7 @@ const tabsStructure = computed(() => {
|
|||||||
doctype: 'Payment Gateway',
|
doctype: 'Payment Gateway',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Default Currency',
|
type: 'Column Break',
|
||||||
name: 'default_currency',
|
|
||||||
type: 'Link',
|
|
||||||
doctype: 'Currency',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Apply GST for India',
|
label: 'Apply GST for India',
|
||||||
@@ -207,9 +237,19 @@ const tabsStructure = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Categories',
|
label: 'Categories',
|
||||||
description: 'Manage the members of your learning system',
|
description: 'Double click to edit the category',
|
||||||
icon: 'Network',
|
icon: 'Network',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Email Templates',
|
||||||
|
description: 'Manage the email templates for your learning system',
|
||||||
|
icon: 'MailPlus',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Zoom Accounts',
|
||||||
|
description: 'Manage the Zoom accounts for your learning system',
|
||||||
|
icon: 'Video',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -235,28 +275,6 @@ const tabsStructure = computed(() => {
|
|||||||
name: 'favicon',
|
name: 'favicon',
|
||||||
type: 'Upload',
|
type: 'Upload',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Footer Logo',
|
|
||||||
name: 'footer_logo',
|
|
||||||
type: 'Upload',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Address',
|
|
||||||
name: 'address',
|
|
||||||
type: 'textarea',
|
|
||||||
rows: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Footer "Powered By"',
|
|
||||||
name: 'footer_powered',
|
|
||||||
type: 'textarea',
|
|
||||||
rows: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Copyright',
|
|
||||||
name: 'copyright',
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -275,8 +293,8 @@ const tabsStructure = computed(() => {
|
|||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Certified Participants',
|
label: 'Certified Members',
|
||||||
name: 'certified_participants',
|
name: 'certified_members',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -299,24 +317,6 @@ const tabsStructure = computed(() => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Email Templates',
|
|
||||||
icon: 'MailPlus',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
label: 'Batch Confirmation Template',
|
|
||||||
name: 'batch_confirmation_template',
|
|
||||||
doctype: 'Email Template',
|
|
||||||
type: 'Link',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Certification Template',
|
|
||||||
name: 'certification_template',
|
|
||||||
doctype: 'Email Template',
|
|
||||||
type: 'Link',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Signup',
|
label: 'Signup',
|
||||||
icon: 'LogIn',
|
icon: 'LogIn',
|
||||||
@@ -335,6 +335,9 @@ const tabsStructure = computed(() => {
|
|||||||
description:
|
description:
|
||||||
'New users will have to be manually registered by Admins.',
|
'New users will have to be manually registered by Admins.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Signup Consent HTML',
|
label: 'Signup Consent HTML',
|
||||||
name: 'custom_signup_content',
|
name: 'custom_signup_content',
|
||||||
@@ -362,12 +365,16 @@ const tabsStructure = computed(() => {
|
|||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
rows: 4,
|
rows: 4,
|
||||||
description:
|
description:
|
||||||
'Keywords for search engines to find your website. Separated by commas.',
|
'Comma separated keywords for search engines to find your website.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Meta Image',
|
label: 'Meta Image',
|
||||||
name: 'meta_image',
|
name: 'meta_image',
|
||||||
type: 'Upload',
|
type: 'Upload',
|
||||||
|
size: 'lg',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
188
frontend/src/components/Settings/ZoomSettings.vue
Normal file
188
frontend/src/components/Settings/ZoomSettings.vue
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-0 text-base">
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<!-- <div class="text-xs text-ink-gray-5">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-5">
|
||||||
|
<Button @click="openForm('new')">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="zoomAccounts.data?.length" class="overflow-y-scroll">
|
||||||
|
<ListView
|
||||||
|
:columns="columns"
|
||||||
|
:rows="zoomAccounts.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
onRowClick: (row) => {
|
||||||
|
openForm(row.name)
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in columns">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5 ml-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in zoomAccounts.data">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<div v-if="column.key == 'enabled'">
|
||||||
|
<Badge v-if="row[column.key]" theme="blue">
|
||||||
|
{{ __('Enabled') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge v-else theme="gray">
|
||||||
|
{{ __('Disabled') }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-else class="leading-5 text-sm">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeAccount(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ZoomAccountModal
|
||||||
|
v-model="showForm"
|
||||||
|
v-model:zoomAccounts="zoomAccounts"
|
||||||
|
:accountID="currentAccount"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
call,
|
||||||
|
createListResource,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
import { User } from '@/components/Settings/types'
|
||||||
|
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
|
||||||
|
|
||||||
|
const user = inject<User | null>('$user')
|
||||||
|
const showForm = ref(false)
|
||||||
|
const currentAccount = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: String,
|
||||||
|
description: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const zoomAccounts = createListResource({
|
||||||
|
doctype: 'LMS Zoom Settings',
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'enabled',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'account_id',
|
||||||
|
'client_id',
|
||||||
|
'client_secret',
|
||||||
|
],
|
||||||
|
cache: ['zoomAccounts'],
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchZoomAccounts()
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchZoomAccounts = () => {
|
||||||
|
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
|
||||||
|
|
||||||
|
if (!user?.data?.is_moderator) {
|
||||||
|
zoomAccounts.update({
|
||||||
|
filters: {
|
||||||
|
member: user.data.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
zoomAccounts.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openForm = (accountID: string) => {
|
||||||
|
currentAccount.value = accountID
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAccount = (selections, unselectAll) => {
|
||||||
|
call('lms.lms.api.delete_documents', {
|
||||||
|
doctype: 'LMS Zoom Settings',
|
||||||
|
documents: Array.from(selections),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
zoomAccounts.reload()
|
||||||
|
toast.success(__('Email Templates deleted successfully'))
|
||||||
|
unselectAll()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error deleting email templates')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Account'),
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Member'),
|
||||||
|
key: 'member_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Status'),
|
||||||
|
key: 'enabled',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
16
frontend/src/components/Settings/types.ts
Normal file
16
frontend/src/components/Settings/types.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface User {
|
||||||
|
data: {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
user_image: string
|
||||||
|
full_name: string
|
||||||
|
user_type: ['System User', 'Website User']
|
||||||
|
username: string
|
||||||
|
is_moderator: boolean
|
||||||
|
is_system_manager: boolean
|
||||||
|
is_evaluator: boolean
|
||||||
|
is_instructor: boolean
|
||||||
|
is_fc_site: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Tooltip, Button } from 'frappe-ui'
|
import { Tooltip } from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import * as icons from 'lucide-vue-next'
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
{{ __('Upcoming Evaluations') }}
|
{{ __('Upcoming Evaluations') }}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-if="
|
v-if="upcoming_evals.data?.length != evaluationCourses.length"
|
||||||
!upcoming_evals.data?.length ||
|
|
||||||
upcoming_evals.length == courses.length
|
|
||||||
"
|
|
||||||
@click="openEvalModal"
|
@click="openEvalModal"
|
||||||
>
|
>
|
||||||
{{ __('Schedule Evaluation') }}
|
{{ __('Schedule Evaluation') }}
|
||||||
@@ -118,7 +115,7 @@ import {
|
|||||||
HeadsetIcon,
|
HeadsetIcon,
|
||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { inject, ref, getCurrentInstance } from 'vue'
|
import { inject, ref, getCurrentInstance, computed } from 'vue'
|
||||||
import { formatTime } from '../utils'
|
import { formatTime } from '../utils'
|
||||||
import { Button, createResource, call } from 'frappe-ui'
|
import { Button, createResource, call } from 'frappe-ui'
|
||||||
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
||||||
@@ -163,6 +160,12 @@ const openEvalCall = (evl) => {
|
|||||||
window.open(evl.google_meet_link, '_blank')
|
window.open(evl.google_meet_link, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const evaluationCourses = computed(() => {
|
||||||
|
return props.courses.filter((course) => {
|
||||||
|
return course.evaluator != ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const cancelEvaluation = (evl) => {
|
const cancelEvaluation = (evl) => {
|
||||||
$dialog({
|
$dialog({
|
||||||
title: __('Cancel this evaluation?'),
|
title: __('Cancel this evaluation?'),
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ import { usersStore } from '@/stores/user'
|
|||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
import { markRaw, watch, ref, onMounted, computed } from 'vue'
|
import { markRaw, watch, ref, onMounted, computed } from 'vue'
|
||||||
import { createDialog } from '@/utils/dialogs'
|
import { createDialog } from '@/utils/dialogs'
|
||||||
import SettingsModal from '@/components/Modals/Settings.vue'
|
import SettingsModal from '@/components/Settings/Settings.vue'
|
||||||
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
|
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|||||||
@@ -1,80 +1,129 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="videoContainer" class="video-block relative group">
|
<div>
|
||||||
<video
|
|
||||||
@timeupdate="updateTime"
|
|
||||||
@ended="videoEnded"
|
|
||||||
@click="togglePlay"
|
|
||||||
oncontextmenu="return false"
|
|
||||||
class="rounded-md border border-gray-100 cursor-pointer"
|
|
||||||
ref="videoRef"
|
|
||||||
>
|
|
||||||
<source :src="fileURL" :type="type" />
|
|
||||||
</video>
|
|
||||||
<div
|
<div
|
||||||
v-if="!playing"
|
v-if="quizzes.length && !showQuiz && readOnly"
|
||||||
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5"
|
||||||
@click="playVideo"
|
|
||||||
>
|
>
|
||||||
<div
|
{{
|
||||||
class="rounded-full p-4 pl-4.5"
|
__('This video contains {0} {1}:').format(
|
||||||
style="
|
quizzes.length,
|
||||||
background: radial-gradient(
|
quizzes.length == 1 ? 'quiz' : 'quizzes'
|
||||||
circle,
|
)
|
||||||
rgba(0, 0, 0, 0.3) 0%,
|
}}
|
||||||
rgba(0, 0, 0, 0.4) 50%
|
|
||||||
);
|
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
|
||||||
"
|
<span> {{ index + 1 }}. {{ quiz.quiz }} </span>
|
||||||
>
|
{{ __('at {0}').format(formatTimestamp(quiz.time)) }}
|
||||||
<Play />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
|
v-if="!showQuiz"
|
||||||
:class="{
|
ref="videoContainer"
|
||||||
'invisible group-hover:visible': playing,
|
class="video-block relative group"
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<Button variant="ghost">
|
<video
|
||||||
<template #icon>
|
@timeupdate="updateTime"
|
||||||
<Play
|
@ended="videoEnded"
|
||||||
v-if="!playing"
|
@click="togglePlay"
|
||||||
@click="playVideo"
|
oncontextmenu="return false"
|
||||||
class="size-4 text-ink-gray-9"
|
class="rounded-md border border-gray-100 cursor-pointer"
|
||||||
/>
|
ref="videoRef"
|
||||||
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
>
|
||||||
</template>
|
<source :src="fileURL" :type="type" />
|
||||||
</Button>
|
</video>
|
||||||
<Button variant="ghost" @click="toggleMute">
|
<div
|
||||||
<template #icon>
|
v-if="!playing"
|
||||||
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
|
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
||||||
<VolumeX v-else class="size-5 text-ink-white" />
|
@click="playVideo"
|
||||||
</template>
|
>
|
||||||
</Button>
|
<div
|
||||||
<input
|
class="rounded-full p-4 pl-4.5"
|
||||||
type="range"
|
style="
|
||||||
min="0"
|
background: radial-gradient(
|
||||||
:max="duration"
|
circle,
|
||||||
step="0.1"
|
rgba(0, 0, 0, 0.3) 0%,
|
||||||
v-model="currentTime"
|
rgba(0, 0, 0, 0.4) 50%
|
||||||
@input="changeCurrentTime"
|
);
|
||||||
class="duration-slider w-full h-1"
|
"
|
||||||
/>
|
>
|
||||||
<span class="text-sm font-semibold">
|
<Play />
|
||||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
<Button variant="ghost" @click="toggleFullscreen">
|
<div
|
||||||
<template #icon>
|
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
|
||||||
<Maximize class="size-5 text-ink-white" />
|
:class="{
|
||||||
</template>
|
'invisible group-hover:visible': playing,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="hover:bg-transparent">
|
||||||
|
<template #icon>
|
||||||
|
<Play
|
||||||
|
v-if="!playing"
|
||||||
|
@click="playVideo"
|
||||||
|
class="size-4 text-ink-gray-9"
|
||||||
|
/>
|
||||||
|
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
:max="duration"
|
||||||
|
step="0.1"
|
||||||
|
v-model="currentTime"
|
||||||
|
@input="changeCurrentTime"
|
||||||
|
class="duration-slider w-full h-1"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="toggleMute"
|
||||||
|
class="hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
|
||||||
|
<VolumeX v-else class="size-5 text-ink-white" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
class="hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Maximize class="size-5 text-ink-white" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Quiz
|
||||||
|
v-if="showQuiz"
|
||||||
|
:quizName="currentQuiz"
|
||||||
|
:inVideo="true"
|
||||||
|
:backToVideo="resumeVideo"
|
||||||
|
/>
|
||||||
|
<div v-if="!readOnly" @click="showQuizModal = true">
|
||||||
|
<Button>
|
||||||
|
{{ __('Add Quiz to Video') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<QuizInVideo
|
||||||
|
v-model="showQuizModal"
|
||||||
|
:quizzes="quizzes"
|
||||||
|
:saveQuizzes="saveQuizzes"
|
||||||
|
:duration="duration"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
import { Button } from 'frappe-ui'
|
import { Button } from 'frappe-ui'
|
||||||
|
import { formatSeconds, formatTimestamp } from '@/utils'
|
||||||
import Play from '@/components/Icons/Play.vue'
|
import Play from '@/components/Icons/Play.vue'
|
||||||
|
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
|
||||||
|
|
||||||
const videoRef = ref(null)
|
const videoRef = ref(null)
|
||||||
const videoContainer = ref(null)
|
const videoContainer = ref(null)
|
||||||
@@ -82,6 +131,10 @@ let playing = ref(false)
|
|||||||
let currentTime = ref(0)
|
let currentTime = ref(0)
|
||||||
let duration = ref(0)
|
let duration = ref(0)
|
||||||
let muted = ref(false)
|
let muted = ref(false)
|
||||||
|
const showQuizModal = ref(false)
|
||||||
|
const showQuiz = ref(false)
|
||||||
|
const currentQuiz = ref(null)
|
||||||
|
const nextQuiz = ref({})
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
file: {
|
file: {
|
||||||
@@ -92,34 +145,81 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'video/mp4',
|
default: 'video/mp4',
|
||||||
},
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: String,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
quizzes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
saveQuizzes: {
|
||||||
|
type: Function,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
updateCurrentTime()
|
||||||
|
updateNextQuiz()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateCurrentTime = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
videoRef.value.onloadedmetadata = () => {
|
videoRef.value.onloadedmetadata = () => {
|
||||||
duration.value = videoRef.value.duration
|
duration.value = videoRef.value.duration
|
||||||
}
|
}
|
||||||
videoRef.value.ontimeupdate = () => {
|
videoRef.value.ontimeupdate = () => {
|
||||||
currentTime.value = videoRef.value.currentTime
|
currentTime.value = videoRef.value?.currentTime || currentTime.value
|
||||||
|
if (currentTime.value >= nextQuiz.value.time) {
|
||||||
|
videoRef.value.pause()
|
||||||
|
playing.value = false
|
||||||
|
videoRef.value.onTimeupdate = null
|
||||||
|
currentQuiz.value = nextQuiz.value.quiz
|
||||||
|
showQuiz.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const resumeVideo = (restart = false) => {
|
||||||
|
showQuiz.value = false
|
||||||
|
currentQuiz.value = null
|
||||||
|
updateCurrentTime()
|
||||||
|
setTimeout(() => {
|
||||||
|
videoRef.value.currentTime = restart ? 0 : currentTime.value
|
||||||
|
videoRef.value.play()
|
||||||
|
playing.value = true
|
||||||
|
updateNextQuiz()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNextQuiz = () => {
|
||||||
|
if (!props.quizzes.length) return
|
||||||
|
|
||||||
|
props.quizzes.forEach((quiz) => {
|
||||||
|
if (typeof quiz.time == 'string' && quiz.time.includes(':')) {
|
||||||
|
let time = quiz.time.split(':')
|
||||||
|
let timeInSeconds = parseInt(time[0]) * 60 + parseInt(time[1])
|
||||||
|
quiz.time = timeInSeconds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
props.quizzes.sort((a, b) => a.time - b.time)
|
||||||
|
|
||||||
|
const nextQuizIndex = props.quizzes.findIndex(
|
||||||
|
(quiz) => quiz.time > currentTime.value
|
||||||
|
)
|
||||||
|
if (nextQuizIndex !== -1) {
|
||||||
|
nextQuiz.value = props.quizzes[nextQuizIndex]
|
||||||
|
} else {
|
||||||
|
nextQuiz.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fileURL = computed(() => {
|
const fileURL = computed(() => {
|
||||||
if (isYoutube) {
|
|
||||||
let url = props.file
|
|
||||||
if (url.includes('watch?v=')) {
|
|
||||||
url = url.replace('watch?v=', 'embed/')
|
|
||||||
}
|
|
||||||
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
|
|
||||||
}
|
|
||||||
return props.file
|
return props.file
|
||||||
})
|
})
|
||||||
|
|
||||||
const isYoutube = computed(() => {
|
|
||||||
return props.type == 'video/youtube'
|
|
||||||
})
|
|
||||||
|
|
||||||
const playVideo = () => {
|
const playVideo = () => {
|
||||||
videoRef.value.play()
|
videoRef.value.play()
|
||||||
playing.value = true
|
playing.value = true
|
||||||
@@ -149,12 +249,7 @@ const toggleMute = () => {
|
|||||||
|
|
||||||
const changeCurrentTime = () => {
|
const changeCurrentTime = () => {
|
||||||
videoRef.value.currentTime = currentTime.value
|
videoRef.value.currentTime = currentTime.value
|
||||||
}
|
updateNextQuiz()
|
||||||
|
|
||||||
const formatTime = (time) => {
|
|
||||||
const minutes = Math.floor(time / 60)
|
|
||||||
const seconds = Math.floor(time % 60)
|
|
||||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
|
|||||||
@@ -70,7 +70,10 @@
|
|||||||
<BatchStudents :batch="batch" />
|
<BatchStudents :batch="batch" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Classes'">
|
<div v-else-if="tab.label == 'Classes'">
|
||||||
<LiveClass :batch="batch.data.name" />
|
<LiveClass
|
||||||
|
:batch="batch.data.name"
|
||||||
|
:zoomAccount="batch.data.zoom_account"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Assessments'">
|
<div v-else-if="tab.label == 'Assessments'">
|
||||||
<Assessments :batch="batch.data.name" />
|
<Assessments :batch="batch.data.name" />
|
||||||
@@ -88,56 +91,61 @@
|
|||||||
:scrollToBottom="false"
|
:scrollToBottom="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Feedback'">
|
|
||||||
<BatchFeedback :batch="batch.data.name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="text-ink-gray-7 font-semibold mb-4">
|
<div class="mb-10">
|
||||||
{{ __('About this batch') }}:
|
<div class="text-ink-gray-7 font-semibold mb-2">
|
||||||
</div>
|
{{ __('About this batch') }}
|
||||||
<div
|
</div>
|
||||||
v-html="batch.data.description"
|
<div
|
||||||
class="leading-5 mb-4 text-ink-gray-7"
|
v-html="batch.data.description"
|
||||||
></div>
|
class="leading-5 mb-4 text-ink-gray-7"
|
||||||
|
></div>
|
||||||
<div class="flex items-center avatar-group overlap mb-5">
|
|
||||||
<div
|
<div class="flex items-center avatar-group overlap mb-5">
|
||||||
class="h-6 mr-1"
|
<div
|
||||||
:class="{
|
class="h-6 mr-1"
|
||||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
:class="{
|
||||||
}"
|
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||||
>
|
}"
|
||||||
<UserAvatar
|
>
|
||||||
v-for="instructor in batch.data.instructors"
|
<UserAvatar
|
||||||
:user="instructor"
|
v-for="instructor in batch.data.instructors"
|
||||||
/>
|
:user="instructor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CourseInstructors :instructors="batch.data.instructors" />
|
||||||
|
</div>
|
||||||
|
<DateRange
|
||||||
|
:startDate="batch.data.start_date"
|
||||||
|
:endDate="batch.data.end_date"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center mb-3 text-ink-gray-7">
|
||||||
|
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
||||||
|
<span>
|
||||||
|
{{ formatTime(batch.data.start_time) }} -
|
||||||
|
{{ formatTime(batch.data.end_time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="batch.data.timezone"
|
||||||
|
class="flex items-center mb-3 text-ink-gray-7"
|
||||||
|
>
|
||||||
|
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
||||||
|
<span>
|
||||||
|
{{ batch.data.timezone }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CourseInstructors :instructors="batch.data.instructors" />
|
|
||||||
</div>
|
</div>
|
||||||
<DateRange
|
<div v-if="dayjs().isSameOrAfter(dayjs(batch.data.start_date))">
|
||||||
:startDate="batch.data.start_date"
|
<div class="text-ink-gray-7 font-semibold mb-2">
|
||||||
:endDate="batch.data.end_date"
|
{{ __('Feedback') }}
|
||||||
class="mb-3"
|
</div>
|
||||||
/>
|
<BatchFeedback :batch="batch.data?.name" />
|
||||||
<div class="flex items-center mb-4 text-ink-gray-7">
|
|
||||||
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
|
||||||
<span>
|
|
||||||
{{ formatTime(batch.data.start_time) }} -
|
|
||||||
{{ formatTime(batch.data.end_time) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="batch.data.timezone"
|
|
||||||
class="flex items-center mb-4 text-ink-gray-7"
|
|
||||||
>
|
|
||||||
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
|
||||||
<span>
|
|
||||||
{{ batch.data.timezone }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AnnouncementModal
|
<AnnouncementModal
|
||||||
@@ -234,6 +242,7 @@ import Discussions from '@/components/Discussions.vue'
|
|||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
|
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
|
||||||
import BatchFeedback from '@/components/BatchFeedback.vue'
|
import BatchFeedback from '@/components/BatchFeedback.vue'
|
||||||
|
import dayjs from 'dayjs/esm'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showAnnouncementModal = ref(false)
|
const showAnnouncementModal = ref(false)
|
||||||
@@ -277,11 +286,6 @@ const tabs = computed(() => {
|
|||||||
label: 'Discussions',
|
label: 'Discussions',
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
})
|
})
|
||||||
|
|
||||||
batchTabs.push({
|
|
||||||
label: 'Feedback',
|
|
||||||
icon: ClipboardPen,
|
|
||||||
})
|
|
||||||
return batchTabs
|
return batchTabs
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -37,14 +37,7 @@
|
|||||||
<BatchOverlay :batch="batch" />
|
<BatchOverlay :batch="batch" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
|
||||||
<div class="order-2 lg:order-none">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="order-1 lg:order-none">
|
|
||||||
<BatchOverlay :batch="batch" />
|
|
||||||
</div>
|
|
||||||
</div> -->
|
|
||||||
<div v-if="batch.data.courses.length">
|
<div v-if="batch.data.courses.length">
|
||||||
<div class="flex items-center mt-10">
|
<div class="flex items-center mt-10">
|
||||||
<div class="text-2xl font-semibold">
|
<div class="text-2xl font-semibold">
|
||||||
|
|||||||
@@ -23,10 +23,10 @@
|
|||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-model="instructors"
|
v-model="instructors"
|
||||||
doctype="User"
|
doctype="Course Evaluator"
|
||||||
:label="__('Instructors')"
|
:label="__('Instructors')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:onCreate="(close) => openSettings('Members', close)"
|
:onCreate="(close) => openSettings('Evaluators', close)"
|
||||||
:filters="{ ignore_user_type: 1 }"
|
:filters="{ ignore_user_type: 1 }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,6 +153,21 @@
|
|||||||
doctype="Email Template"
|
doctype="Email Template"
|
||||||
:label="__('Email Template')"
|
:label="__('Email Template')"
|
||||||
v-model="batch.confirmation_email_template"
|
v-model="batch.confirmation_email_template"
|
||||||
|
:onCreate="
|
||||||
|
(value, close) => {
|
||||||
|
openSettings('Email Templates', close)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
doctype="LMS Zoom Settings"
|
||||||
|
:label="__('Zoom Account')"
|
||||||
|
v-model="batch.zoom_account"
|
||||||
|
:onCreate="
|
||||||
|
(value, close) => {
|
||||||
|
openSettings('Zoom Accounts', close)
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
@@ -258,6 +273,27 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="px-20 pb-5 space-y-5 border-b">
|
||||||
|
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||||
|
{{ __('Meta Tags') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="meta.description"
|
||||||
|
:label="__('Meta Description')"
|
||||||
|
type="textarea"
|
||||||
|
:rows="7"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="meta.keywords"
|
||||||
|
:label="__('Meta Keywords')"
|
||||||
|
type="textarea"
|
||||||
|
:rows="7"
|
||||||
|
:placeholder="__('Comma separated keywords for SEO')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -287,12 +323,13 @@ import { useOnboarding } from 'frappe-ui/frappe'
|
|||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { openSettings } from '@/utils'
|
import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
|
const instructors = ref([])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batchName: {
|
batchName: {
|
||||||
@@ -322,20 +359,29 @@ const batch = reactive({
|
|||||||
paid_batch: false,
|
paid_batch: false,
|
||||||
currency: '',
|
currency: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
|
zoom_account: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const instructors = ref([])
|
const meta = reactive({
|
||||||
|
description: '',
|
||||||
|
keywords: '',
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data) window.location.href = '/login'
|
if (!user.data) window.location.href = '/login'
|
||||||
if (props.batchName != 'new') {
|
if (props.batchName != 'new') {
|
||||||
batchDetail.reload()
|
fetchBatchInfo()
|
||||||
} else {
|
} else {
|
||||||
capture('batch_form_opened')
|
capture('batch_form_opened')
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fetchBatchInfo = () => {
|
||||||
|
batchDetail.reload()
|
||||||
|
getMetaInfo('batches', props.batchName, meta)
|
||||||
|
}
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
const keyboardShortcut = (e) => {
|
||||||
if (
|
if (
|
||||||
e.key === 's' &&
|
e.key === 's' &&
|
||||||
@@ -449,7 +495,7 @@ const createNewBatch = () => {
|
|||||||
localStorage.setItem('firstBatch', data.name)
|
localStorage.setItem('firstBatch', data.name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
updateMetaInfo('batches', data.name, meta)
|
||||||
capture('batch_created')
|
capture('batch_created')
|
||||||
router.push({
|
router.push({
|
||||||
name: 'BatchDetail',
|
name: 'BatchDetail',
|
||||||
@@ -470,6 +516,7 @@ const editBatchDetails = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
updateMetaInfo('batches', data.name, meta)
|
||||||
router.push({
|
router.push({
|
||||||
name: 'BatchDetail',
|
name: 'BatchDetail',
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -20,14 +20,12 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="p-5 pb-10">
|
<div class="p-5 pb-10">
|
||||||
<div
|
<div
|
||||||
v-if="batchCount"
|
|
||||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
||||||
>
|
>
|
||||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||||
{{ __('All Batches') }}
|
{{ __('All Batches') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="batches.data?.length || batchCount"
|
|
||||||
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
||||||
>
|
>
|
||||||
<TabButtons
|
<TabButtons
|
||||||
@@ -115,12 +113,10 @@ const is_student = computed(() => user.data?.is_student)
|
|||||||
const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
|
const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
|
||||||
const orderBy = ref('start_date')
|
const orderBy = ref('start_date')
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
const batchCount = ref(0)
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setFiltersFromQuery()
|
setFiltersFromQuery()
|
||||||
updateBatches()
|
updateBatches()
|
||||||
getBatchCount()
|
|
||||||
categories.value = [
|
categories.value = [
|
||||||
{
|
{
|
||||||
label: '',
|
label: '',
|
||||||
@@ -298,14 +294,6 @@ const canCreateBatch = () => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBatchCount = () => {
|
|
||||||
call('frappe.client.get_count', {
|
|
||||||
doctype: 'LMS Batch',
|
|
||||||
}).then((data) => {
|
|
||||||
batchCount.value = data
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => [
|
const breadcrumbs = computed(() => [
|
||||||
{
|
{
|
||||||
label: __('Batches'),
|
label: __('Batches'),
|
||||||
|
|||||||
@@ -12,10 +12,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div class="mx-auto w-full max-w-4xl pt-6 pb-10">
|
||||||
v-if="participants.data?.length"
|
|
||||||
class="mx-auto w-full max-w-4xl pt-6 pb-10"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
|
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
|
||||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
||||||
{{ memberCount }} {{ __('certified members') }}
|
{{ memberCount }} {{ __('certified members') }}
|
||||||
@@ -41,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y">
|
<div v-if="participants.data?.length" class="divide-y">
|
||||||
<template v-for="participant in participants.data">
|
<template v-for="participant in participants.data">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
@@ -92,6 +89,7 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyState v-else type="Certified Members" />
|
||||||
<div
|
<div
|
||||||
v-if="!participants.list.loading && participants.hasNextPage"
|
v-if="!participants.list.loading && participants.hasNextPage"
|
||||||
class="flex justify-center mt-5"
|
class="flex justify-center mt-5"
|
||||||
@@ -101,7 +99,6 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState v-else type="Certified Members" />
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
@@ -127,22 +124,24 @@ const memberCount = ref(0)
|
|||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
getMemberCount()
|
||||||
updateParticipants()
|
updateParticipants()
|
||||||
})
|
})
|
||||||
|
|
||||||
const participants = createListResource({
|
const participants = createListResource({
|
||||||
doctype: 'LMS Certificate',
|
doctype: 'LMS Certificate',
|
||||||
url: 'lms.lms.api.get_certified_participants',
|
url: 'lms.lms.api.get_certified_participants',
|
||||||
cache: ['certified_participants'],
|
|
||||||
start: 0,
|
start: 0,
|
||||||
pageLength: 30,
|
pageLength: 100,
|
||||||
})
|
})
|
||||||
|
|
||||||
const count = call('lms.lms.api.get_count_of_certified_members').then(
|
const getMemberCount = () => {
|
||||||
(data) => {
|
call('lms.lms.api.get_count_of_certified_members', {
|
||||||
|
filters: filters.value,
|
||||||
|
}).then((data) => {
|
||||||
memberCount.value = data
|
memberCount.value = data
|
||||||
}
|
})
|
||||||
)
|
}
|
||||||
|
|
||||||
const categories = createListResource({
|
const categories = createListResource({
|
||||||
doctype: 'LMS Certificate',
|
doctype: 'LMS Certificate',
|
||||||
@@ -157,6 +156,7 @@ const categories = createListResource({
|
|||||||
|
|
||||||
const updateParticipants = () => {
|
const updateParticipants = () => {
|
||||||
updateFilters()
|
updateFilters()
|
||||||
|
getMemberCount()
|
||||||
participants.update({
|
participants.update({
|
||||||
filters: filters.value,
|
filters: filters.value,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
:text="__('Average Rating')"
|
:text="__('Average Rating')"
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
>
|
>
|
||||||
<Star class="h-5 w-5 text-gray-100 fill-orange-500" />
|
<Star class="size-4 text-transparent fill-yellow-500" />
|
||||||
<span class="ml-1 text-ink-gray-7">
|
<span class="ml-1 text-ink-gray-7">
|
||||||
{{ course.data.rating }}
|
{{ course.data.rating }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
<FormControl
|
<FormControl
|
||||||
v-model="course.short_introduction"
|
v-model="course.short_introduction"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="4"
|
:rows="5"
|
||||||
:label="__('Short Introduction')"
|
:label="__('Short Introduction')"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
__(
|
__(
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-10 pb-5 space-y-5">
|
<div class="px-10 pb-5 space-y-5 border-b">
|
||||||
<div class="text-lg font-semibold mt-5">
|
<div class="text-lg font-semibold mt-5">
|
||||||
{{ __('Pricing and Certification') }}
|
{{ __('Pricing and Certification') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,6 +248,27 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="px-10 pb-5 space-y-5">
|
||||||
|
<div class="text-lg font-semibold mt-5">
|
||||||
|
{{ __('Meta Tags') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="meta.description"
|
||||||
|
:label="__('Meta Description')"
|
||||||
|
type="textarea"
|
||||||
|
:rows="7"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="meta.keywords"
|
||||||
|
:label="__('Meta Keywords')"
|
||||||
|
type="textarea"
|
||||||
|
:rows="7"
|
||||||
|
:placeholder="__('Comma separated keywords for SEO')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-l">
|
<div class="border-l">
|
||||||
@@ -264,6 +285,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
|
call,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
@@ -284,10 +306,10 @@ import {
|
|||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { Image, Trash2, X } from 'lucide-vue-next'
|
import { Image, Trash2, X } from 'lucide-vue-next'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { capture } from '@/telemetry'
|
import { capture, startRecording, stopRecording } from '@/telemetry'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import { openSettings } from '@/utils'
|
import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
@@ -328,19 +350,30 @@ const course = reactive({
|
|||||||
evaluator: '',
|
evaluator: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const meta = reactive({
|
||||||
|
description: '',
|
||||||
|
keywords: '',
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||||
router.push({ name: 'Courses' })
|
router.push({ name: 'Courses' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.courseName !== 'new') {
|
if (props.courseName !== 'new') {
|
||||||
courseResource.reload()
|
fetchCourseInfo()
|
||||||
} else {
|
} else {
|
||||||
capture('course_form_opened')
|
capture('course_form_opened')
|
||||||
|
startRecording()
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fetchCourseInfo = () => {
|
||||||
|
courseResource.reload()
|
||||||
|
getMetaInfo('courses', props.courseName, meta)
|
||||||
|
}
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
const keyboardShortcut = (e) => {
|
||||||
if (
|
if (
|
||||||
e.key === 's' &&
|
e.key === 's' &&
|
||||||
@@ -354,6 +387,7 @@ const keyboardShortcut = (e) => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('keydown', keyboardShortcut)
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
|
stopRecording()
|
||||||
})
|
})
|
||||||
|
|
||||||
const courseCreationResource = createResource({
|
const courseCreationResource = createResource({
|
||||||
@@ -442,40 +476,50 @@ const imageResource = createResource({
|
|||||||
|
|
||||||
const submitCourse = () => {
|
const submitCourse = () => {
|
||||||
if (courseResource.data) {
|
if (courseResource.data) {
|
||||||
courseEditResource.submit(
|
editCourse()
|
||||||
{
|
|
||||||
course: courseResource.data.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
toast.success(__('Course updated successfully'))
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
courseCreationResource.submit(course, {
|
createCourse()
|
||||||
onSuccess(data) {
|
}
|
||||||
if (user.data?.is_system_manager) {
|
}
|
||||||
updateOnboardingStep('create_first_course', true, false, () => {
|
|
||||||
localStorage.setItem('firstCourse', data.name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
capture('course_created')
|
const createCourse = () => {
|
||||||
toast.success(__('Course created successfully'))
|
courseCreationResource.submit(course, {
|
||||||
router.push({
|
onSuccess(data) {
|
||||||
name: 'CourseForm',
|
updateMetaInfo('courses', data.name, meta)
|
||||||
params: { courseName: data.name },
|
if (user.data?.is_system_manager) {
|
||||||
|
updateOnboardingStep('create_first_course', true, false, () => {
|
||||||
|
localStorage.setItem('firstCourse', data.name)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
capture('course_created')
|
||||||
|
toast.success(__('Course created successfully'))
|
||||||
|
router.push({
|
||||||
|
name: 'CourseForm',
|
||||||
|
params: { courseName: data.name },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(err.messages?.[0] || err)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const editCourse = () => {
|
||||||
|
courseEditResource.submit(
|
||||||
|
{
|
||||||
|
course: courseResource.data.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
updateMetaInfo('courses', props.courseName, meta)
|
||||||
|
toast.success(__('Course updated successfully'))
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
toast.error(err.messages?.[0] || err)
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteCourse = createResource({
|
const deleteCourse = createResource({
|
||||||
@@ -515,7 +559,7 @@ watch(
|
|||||||
() => props.courseName !== 'new',
|
() => props.courseName !== 'new',
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
courseResource.reload()
|
fetchCourseInfo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,14 +20,12 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="p-5 pb-10">
|
<div class="p-5 pb-10">
|
||||||
<div
|
<div
|
||||||
v-if="courseCount"
|
|
||||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
||||||
>
|
>
|
||||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||||
{{ __('All Courses') }}
|
{{ __('All Courses') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="courses.data?.length || courseCount"
|
|
||||||
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
||||||
>
|
>
|
||||||
<TabButtons :buttons="courseTabs" v-model="currentTab" />
|
<TabButtons :buttons="courseTabs" v-model="currentTab" />
|
||||||
@@ -172,6 +170,8 @@ const identifyUserPersona = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getCourseCount = () => {
|
const getCourseCount = () => {
|
||||||
|
if (!user.data) return
|
||||||
|
|
||||||
call('frappe.client.get_count', {
|
call('frappe.client.get_count', {
|
||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
}).then((data) => {
|
}).then((data) => {
|
||||||
@@ -240,7 +240,6 @@ const updateTabFilter = () => {
|
|||||||
filters.value['live'] = 1
|
filters.value['live'] = 1
|
||||||
} else if (currentTab.value == 'Upcoming') {
|
} else if (currentTab.value == 'Upcoming') {
|
||||||
filters.value['upcoming'] = 1
|
filters.value['upcoming'] = 1
|
||||||
filters.value['published'] = 1
|
|
||||||
} else if (currentTab.value == 'New') {
|
} else if (currentTab.value == 'New') {
|
||||||
filters.value['published'] = 1
|
filters.value['published'] = 1
|
||||||
filters.value['published_on'] = [
|
filters.value['published_on'] = [
|
||||||
@@ -249,6 +248,8 @@ const updateTabFilter = () => {
|
|||||||
]
|
]
|
||||||
} else if (currentTab.value == 'Created') {
|
} else if (currentTab.value == 'Created') {
|
||||||
filters.value['created'] = 1
|
filters.value['created'] = 1
|
||||||
|
} else if (currentTab.value == 'Unpublished') {
|
||||||
|
filters.value['published'] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +319,7 @@ const courseTabs = computed(() => {
|
|||||||
user.data?.is_evaluator
|
user.data?.is_evaluator
|
||||||
) {
|
) {
|
||||||
tabs.push({ label: __('Created') })
|
tabs.push({ label: __('Created') })
|
||||||
|
tabs.push({ label: __('Unpublished') })
|
||||||
} else if (user.data) {
|
} else if (user.data) {
|
||||||
tabs.push({ label: __('Enrolled') })
|
tabs.push({ label: __('Enrolled') })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
</header>
|
</header>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
v-if="jobCount"
|
|
||||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
|
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
|
||||||
>
|
>
|
||||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
||||||
@@ -34,8 +33,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="jobs.data?.length || jobCount > 0"
|
class="grid grid-cols-1 gap-2"
|
||||||
class="grid grid-cols-1 md:grid-cols-3 gap-2"
|
:class="user.data ? 'md:grid-cols-3' : 'md:grid-cols-2'"
|
||||||
>
|
>
|
||||||
<FormControl
|
<FormControl
|
||||||
type="text"
|
type="text"
|
||||||
@@ -52,6 +51,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Link
|
<Link
|
||||||
|
v-if="user.data"
|
||||||
doctype="Country"
|
doctype="Country"
|
||||||
v-model="country"
|
v-model="country"
|
||||||
:placeholder="__('Country')"
|
:placeholder="__('Country')"
|
||||||
@@ -117,12 +117,14 @@ onMounted(() => {
|
|||||||
jobType.value = queries.get('type')
|
jobType.value = queries.get('type')
|
||||||
}
|
}
|
||||||
updateJobs()
|
updateJobs()
|
||||||
getJobCount()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const jobs = createResource({
|
const jobs = createResource({
|
||||||
url: 'lms.lms.api.get_job_opportunities',
|
url: 'lms.lms.api.get_job_opportunities',
|
||||||
cache: ['jobs'],
|
cache: ['jobs'],
|
||||||
|
onSuccess(data) {
|
||||||
|
jobCount.value = data.length
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateJobs = () => {
|
const updateJobs = () => {
|
||||||
@@ -163,18 +165,6 @@ const updateFilters = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getJobCount = () => {
|
|
||||||
call('frappe.client.get_count', {
|
|
||||||
doctype: 'Job Opportunity',
|
|
||||||
filters: {
|
|
||||||
status: 'Open',
|
|
||||||
disabled: 0,
|
|
||||||
},
|
|
||||||
}).then((data) => {
|
|
||||||
jobCount.value = data
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(country, (val) => {
|
watch(country, (val) => {
|
||||||
updateJobs()
|
updateJobs()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ import ProgressBar from '@/components/ProgressBar.vue'
|
|||||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const socket = inject('$socket')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const allowDiscussions = ref(false)
|
const allowDiscussions = ref(false)
|
||||||
@@ -335,6 +336,11 @@ const props = defineProps({
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startTimer()
|
startTimer()
|
||||||
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
||||||
|
socket.on('update_lesson_progress', (data) => {
|
||||||
|
if (data.course === props.courseName) {
|
||||||
|
lessonProgress.value = data.progress
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const attachFullscreenEvent = () => {
|
const attachFullscreenEvent = () => {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ import EditorJS from '@editorjs/editorjs'
|
|||||||
import LessonHelp from '@/components/LessonHelp.vue'
|
import LessonHelp from '@/components/LessonHelp.vue'
|
||||||
import { ChevronRight } from 'lucide-vue-next'
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
import { getEditorTools, enablePlyr } from '@/utils'
|
import { getEditorTools, enablePlyr } from '@/utils'
|
||||||
import { capture } from '@/telemetry'
|
import { capture, startRecording, stopRecording } from '@/telemetry'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
@@ -131,6 +131,7 @@ onMounted(() => {
|
|||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
capture('lesson_form_opened')
|
capture('lesson_form_opened')
|
||||||
|
startRecording()
|
||||||
editor.value = renderEditor('content')
|
editor.value = renderEditor('content')
|
||||||
instructorEditor.value = renderEditor('instructor-notes')
|
instructorEditor.value = renderEditor('instructor-notes')
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
@@ -209,7 +210,7 @@ const addInstructorNotes = (data) => {
|
|||||||
const enableAutoSave = () => {
|
const enableAutoSave = () => {
|
||||||
autoSaveInterval = setInterval(() => {
|
autoSaveInterval = setInterval(() => {
|
||||||
saveLesson({ showSuccessMessage: false })
|
saveLesson({ showSuccessMessage: false })
|
||||||
}, 10000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
const keyboardShortcut = (e) => {
|
||||||
@@ -226,6 +227,7 @@ const keyboardShortcut = (e) => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearInterval(autoSaveInterval)
|
clearInterval(autoSaveInterval)
|
||||||
window.removeEventListener('keydown', keyboardShortcut)
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
|
stopRecording()
|
||||||
})
|
})
|
||||||
|
|
||||||
const newLessonResource = createResource({
|
const newLessonResource = createResource({
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="notifications?.length"
|
v-if="notifications?.length"
|
||||||
v-for="log in notifications"
|
v-for="log in notifications"
|
||||||
|
:key="log.name"
|
||||||
class="flex items-center py-2 justify-between"
|
class="flex items-center py-2 justify-between"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -32,22 +33,20 @@
|
|||||||
<Link
|
<Link
|
||||||
v-if="log.link"
|
v-if="log.link"
|
||||||
:to="log.link"
|
:to="log.link"
|
||||||
@click="markAsRead.submit({ name: log.name })"
|
@click="(e) => handleMarkAsRead(e, log.name)"
|
||||||
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
|
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
|
||||||
>
|
>
|
||||||
{{ __('View') }}
|
{{ __('View') }}
|
||||||
</Link>
|
</Link>
|
||||||
<Tooltip :text="__('Mark as read')">
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
v-if="!log.read"
|
||||||
v-if="!log.read"
|
@click.stop="(e) => handleMarkAsRead(e, log.name)"
|
||||||
@click="markAsRead.submit({ name: log.name })"
|
>
|
||||||
>
|
<template #icon>
|
||||||
<template #icon>
|
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||||
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
</template>
|
||||||
</template>
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-ink-gray-5">
|
<div v-else class="text-ink-gray-5">
|
||||||
@@ -64,11 +63,10 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
TabButtons,
|
TabButtons,
|
||||||
Button,
|
Button,
|
||||||
Tooltip,
|
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import { computed, inject, ref, onMounted } from 'vue'
|
import { computed, inject, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -135,6 +133,14 @@ const markAllAsRead = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleMarkAsRead = (e, logName) => {
|
||||||
|
markAsRead.submit({ name: logName })
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_lms_notifications')
|
||||||
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const evaluations = createListResource({
|
|||||||
doctype: 'LMS Certificate Request',
|
doctype: 'LMS Certificate Request',
|
||||||
filters: {
|
filters: {
|
||||||
evaluator: user.data?.name,
|
evaluator: user.data?.name,
|
||||||
|
status: ['!=', 'Cancelled'],
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
'name',
|
'name',
|
||||||
|
|||||||
@@ -1,98 +1,90 @@
|
|||||||
import { useStorage } from "@vueuse/core";
|
import '../../../frappe/frappe/public/js/lib/posthog.js'
|
||||||
import { call } from "frappe-ui";
|
import { createResource } from 'frappe-ui'
|
||||||
import "../../../frappe/frappe/public/js/lib/posthog.js";
|
|
||||||
|
|
||||||
const APP = "lms";
|
|
||||||
const SITENAME = window.location.hostname;
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
posthog: any;
|
posthog: any
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const telemetry = useStorage("telemetry", {
|
type PosthogSettings = {
|
||||||
enabled: false,
|
posthog_project_id: string
|
||||||
project_id: "",
|
posthog_host: string
|
||||||
host: "",
|
enable_telemetry: boolean
|
||||||
});
|
telemetry_site_age: number
|
||||||
|
|
||||||
export async function init() {
|
|
||||||
await set_enabled();
|
|
||||||
if (!telemetry.value.enabled) return;
|
|
||||||
try {
|
|
||||||
await set_credentials();
|
|
||||||
window.posthog.init(telemetry.value.project_id, {
|
|
||||||
api_host: telemetry.value.host,
|
|
||||||
autocapture: false,
|
|
||||||
person_profiles: "always",
|
|
||||||
capture_pageview: true,
|
|
||||||
capture_pageleave: true,
|
|
||||||
disable_session_recording: false,
|
|
||||||
session_recording: {
|
|
||||||
maskAllInputs: false,
|
|
||||||
maskInputOptions: {
|
|
||||||
password: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
loaded: (posthog) => {
|
|
||||||
window.posthog = posthog;
|
|
||||||
window.posthog.identify(SITENAME);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.trace("Failed to initialize telemetry", e);
|
|
||||||
telemetry.value.enabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function set_enabled() {
|
|
||||||
if (telemetry.value.enabled) return;
|
|
||||||
|
|
||||||
await call("lms.lms.telemetry.is_enabled").then((res) => {
|
|
||||||
telemetry.value.enabled = res;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function set_credentials() {
|
|
||||||
if (!telemetry.value.enabled) return;
|
|
||||||
if (telemetry.value.project_id && telemetry.value.host) return;
|
|
||||||
|
|
||||||
await call("lms.lms.telemetry.get_credentials").then((res) => {
|
|
||||||
telemetry.value.project_id = res.project_id;
|
|
||||||
telemetry.value.host = res.telemetry_host;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CaptureOptions {
|
interface CaptureOptions {
|
||||||
data: {
|
data: {
|
||||||
user: string;
|
user: string
|
||||||
[key: string]: string | number | boolean | object;
|
[key: string]: string | number | boolean | object
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function capture(
|
let posthog: typeof window.posthog = window.posthog
|
||||||
|
|
||||||
|
// Posthog Settings
|
||||||
|
let posthogSettings = createResource({
|
||||||
|
url: 'lms.lms.telemetry.get_posthog_settings',
|
||||||
|
cache: 'posthog_settings',
|
||||||
|
onSuccess: (ps: PosthogSettings) => initPosthog(ps),
|
||||||
|
})
|
||||||
|
|
||||||
|
let isTelemetryEnabled = () => {
|
||||||
|
if (!posthogSettings.data) return false
|
||||||
|
|
||||||
|
return (
|
||||||
|
posthogSettings.data.enable_telemetry &&
|
||||||
|
posthogSettings.data.posthog_project_id &&
|
||||||
|
posthogSettings.data.posthog_host
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posthog Initialization
|
||||||
|
function initPosthog(ps: PosthogSettings) {
|
||||||
|
if (!isTelemetryEnabled()) return
|
||||||
|
|
||||||
|
posthog.init(ps.posthog_project_id, {
|
||||||
|
api_host: ps.posthog_host,
|
||||||
|
person_profiles: 'identified_only',
|
||||||
|
autocapture: false,
|
||||||
|
capture_pageview: true,
|
||||||
|
capture_pageleave: true,
|
||||||
|
enable_heatmaps: false,
|
||||||
|
disable_session_recording: false,
|
||||||
|
loaded: (ph: typeof posthog) => {
|
||||||
|
window.posthog = ph
|
||||||
|
ph.identify(window.location.hostname)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posthog Functions
|
||||||
|
function capture(
|
||||||
event: string,
|
event: string,
|
||||||
options: CaptureOptions = { data: { user: "" } }
|
options: CaptureOptions = { data: { user: '' } },
|
||||||
) {
|
) {
|
||||||
if (!telemetry.value.enabled) return;
|
if (!isTelemetryEnabled()) return
|
||||||
window.posthog.capture(`${APP}_${event}`, options);
|
window.posthog.capture(`lms_${event}`, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordSession() {
|
function startRecording() {
|
||||||
if (!telemetry.value.enabled) return;
|
|
||||||
if (window.posthog && window.posthog.__loaded) {
|
|
||||||
window.posthog.startSessionRecording();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopSession() {
|
function stopRecording() {
|
||||||
if (!telemetry.value.enabled) return;
|
}
|
||||||
if (
|
|
||||||
window.posthog &&
|
// Posthog Plugin
|
||||||
window.posthog.__loaded &&
|
function posthogPlugin(app: any) {
|
||||||
window.posthog.sessionRecordingStarted()
|
app.config.globalProperties.posthog = posthog
|
||||||
) {
|
if (!window.posthog?.length) posthogSettings.fetch()
|
||||||
window.posthog.stopSessionRecording();
|
}
|
||||||
}
|
|
||||||
|
export {
|
||||||
|
posthog,
|
||||||
|
posthogSettings,
|
||||||
|
posthogPlugin,
|
||||||
|
capture,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { watch } from 'vue'
|
||||||
|
import { call, toast } from 'frappe-ui'
|
||||||
import { useTimeAgo } from '@vueuse/core'
|
import { useTimeAgo } from '@vueuse/core'
|
||||||
import { Quiz } from '@/utils/quiz'
|
import { Quiz } from '@/utils/quiz'
|
||||||
import { Assignment } from '@/utils/assignment'
|
import { Assignment } from '@/utils/assignment'
|
||||||
@@ -10,7 +12,6 @@ import Paragraph from '@editorjs/paragraph'
|
|||||||
import { CodeBox } from '@/utils/code'
|
import { CodeBox } from '@/utils/code'
|
||||||
import NestedList from '@editorjs/nested-list'
|
import NestedList from '@editorjs/nested-list'
|
||||||
import InlineCode from '@editorjs/inline-code'
|
import InlineCode from '@editorjs/inline-code'
|
||||||
import { watch } from 'vue'
|
|
||||||
import dayjs from '@/utils/dayjs'
|
import dayjs from '@/utils/dayjs'
|
||||||
import Embed from '@editorjs/embed'
|
import Embed from '@editorjs/embed'
|
||||||
import SimpleImage from '@editorjs/simple-image'
|
import SimpleImage from '@editorjs/simple-image'
|
||||||
@@ -27,20 +28,21 @@ export function timeAgo(date) {
|
|||||||
export function formatTime(timeString) {
|
export function formatTime(timeString) {
|
||||||
if (!timeString) return ''
|
if (!timeString) return ''
|
||||||
const [hour, minute] = timeString.split(':').map(Number)
|
const [hour, minute] = timeString.split(':').map(Number)
|
||||||
|
|
||||||
// Create a Date object with dummy values for day, month, and year
|
|
||||||
const dummyDate = new Date(0, 0, 0, hour, minute)
|
const dummyDate = new Date(0, 0, 0, hour, minute)
|
||||||
|
|
||||||
// Use Intl.DateTimeFormat to format the time in 12-hour format
|
|
||||||
const formattedTime = new Intl.DateTimeFormat('en-US', {
|
const formattedTime = new Intl.DateTimeFormat('en-US', {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: 'numeric',
|
minute: 'numeric',
|
||||||
hour12: true,
|
hour12: true,
|
||||||
}).format(dummyDate)
|
}).format(dummyDate)
|
||||||
|
|
||||||
return formattedTime
|
return formattedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatSeconds = (time) => {
|
||||||
|
const minutes = Math.floor(time / 60)
|
||||||
|
const seconds = Math.floor(time % 60)
|
||||||
|
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
export function formatNumber(number) {
|
export function formatNumber(number) {
|
||||||
return number.toLocaleString('en-IN', {
|
return number.toLocaleString('en-IN', {
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
@@ -561,3 +563,62 @@ export const openSettings = (category, close) => {
|
|||||||
settingsStore.activeTab = category
|
settingsStore.activeTab = category
|
||||||
settingsStore.isSettingsOpen = true
|
settingsStore.isSettingsOpen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const cleanError = (message) => {
|
||||||
|
// Remove HTML tags but keep the text within the tags
|
||||||
|
|
||||||
|
const cleanMessage = message.replace(/<[^>]+>/g, (match) => {
|
||||||
|
return match.replace(/<\/?[^>]+(>|$)/g, '')
|
||||||
|
})
|
||||||
|
return cleanMessage
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/`/g, '`')
|
||||||
|
.replace(/=/g, '=')
|
||||||
|
.replace(///g, '/')
|
||||||
|
.replace(/,/g, ',')
|
||||||
|
.replace(/;/g, ';')
|
||||||
|
.replace(/:/g, ':')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMetaInfo = (type, route, meta) => {
|
||||||
|
call('lms.lms.api.get_meta_info', {
|
||||||
|
type: type,
|
||||||
|
route: route,
|
||||||
|
}).then((data) => {
|
||||||
|
if (data.length) {
|
||||||
|
data.forEach((row) => {
|
||||||
|
if (row.key == 'description') {
|
||||||
|
meta.description = row.value
|
||||||
|
} else if (row.key == 'keywords') {
|
||||||
|
meta.keywords = row.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateMetaInfo = (type, route, meta) => {
|
||||||
|
call('lms.lms.api.update_meta_info', {
|
||||||
|
type: type,
|
||||||
|
route: route,
|
||||||
|
meta_tags: [
|
||||||
|
{ key: 'description', value: meta.description },
|
||||||
|
{ key: 'keywords', value: meta.keywords },
|
||||||
|
],
|
||||||
|
}).catch((error) => {
|
||||||
|
toast.error(__('Failed to update meta tags {0}').format(error))
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatTimestamp = (seconds) => {
|
||||||
|
const date = new Date(seconds * 1000)
|
||||||
|
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
|
||||||
|
const secs = String(date.getUTCSeconds()).padStart(2, '0')
|
||||||
|
return `${minutes}:${secs}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,14 @@ export class Upload {
|
|||||||
if (this.isVideo(file.file_type)) {
|
if (this.isVideo(file.file_type)) {
|
||||||
const app = createApp(VideoBlock, {
|
const app = createApp(VideoBlock, {
|
||||||
file: file.file_url,
|
file: file.file_url,
|
||||||
|
readOnly: this.readOnly,
|
||||||
|
quizzes: file.quizzes || [],
|
||||||
|
saveQuizzes: (quizzes) => {
|
||||||
|
if (this.readOnly) return
|
||||||
|
this.data.quizzes = quizzes
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
app.use(translationPlugin)
|
||||||
app.mount(this.wrapper)
|
app.mount(this.wrapper)
|
||||||
return
|
return
|
||||||
} else if (this.isAudio(file.file_type)) {
|
} else if (this.isAudio(file.file_type)) {
|
||||||
@@ -93,6 +100,7 @@ export class Upload {
|
|||||||
return {
|
return {
|
||||||
file_url: this.data.file_url,
|
file_url: this.data.file_url,
|
||||||
file_type: this.data.file_type,
|
file_type: this.data.file_type,
|
||||||
|
quizzes: this.data.quizzes || [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "2.28.1"
|
__version__ = "2.30.0"
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ scheduler_events = {
|
|||||||
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
|
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
|
||||||
"lms.lms.api.update_course_statistics",
|
"lms.lms.api.update_course_statistics",
|
||||||
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.mark_eval_as_completed",
|
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.mark_eval_as_completed",
|
||||||
|
"lms.lms.doctype.lms_live_class.lms_live_class.update_attendance",
|
||||||
],
|
],
|
||||||
"daily": [
|
"daily": [
|
||||||
"lms.job.doctype.job_opportunity.job_opportunity.update_job_openings",
|
"lms.job.doctype.job_opportunity.job_opportunity.update_job_openings",
|
||||||
|
|||||||
107
lms/lms/api.py
107
lms/lms/api.py
@@ -20,7 +20,6 @@ from frappe.utils import (
|
|||||||
date_diff,
|
date_diff,
|
||||||
)
|
)
|
||||||
from frappe.query_builder import DocType
|
from frappe.query_builder import DocType
|
||||||
from pypika.functions import DistinctOptionFunction
|
|
||||||
from lms.lms.utils import get_average_rating, get_lesson_count
|
from lms.lms.utils import get_average_rating, get_lesson_count
|
||||||
from xml.dom.minidom import parseString
|
from xml.dom.minidom import parseString
|
||||||
from lms.lms.doctype.course_lesson.course_lesson import save_progress
|
from lms.lms.doctype.course_lesson.course_lesson import save_progress
|
||||||
@@ -413,7 +412,7 @@ def get_evaluator_details(evaluator):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_certified_participants(filters=None, start=0, page_length=30):
|
def get_certified_participants(filters=None, start=0, page_length=100):
|
||||||
or_filters = {}
|
or_filters = {}
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = {}
|
filters = {}
|
||||||
@@ -451,23 +450,29 @@ def get_certified_participants(filters=None, start=0, page_length=30):
|
|||||||
return participants
|
return participants
|
||||||
|
|
||||||
|
|
||||||
class CountDistinct(DistinctOptionFunction):
|
|
||||||
def __init__(self, field):
|
|
||||||
super().__init__("COUNT", field, distinct=True)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_count_of_certified_members():
|
def get_count_of_certified_members(filters=None):
|
||||||
Certificate = DocType("LMS Certificate")
|
Certificate = DocType("LMS Certificate")
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
frappe.qb.from_(Certificate)
|
frappe.qb.from_(Certificate)
|
||||||
.select(CountDistinct(Certificate.member).as_("total"))
|
.select(Certificate.member)
|
||||||
|
.distinct()
|
||||||
.where(Certificate.published == 1)
|
.where(Certificate.published == 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
for field, value in filters.items():
|
||||||
|
if field == "category":
|
||||||
|
query = query.where(
|
||||||
|
Certificate.course_title.like(f"%{value}%")
|
||||||
|
| Certificate.batch_title.like(f"%{value}%")
|
||||||
|
)
|
||||||
|
elif field == "member_name":
|
||||||
|
query = query.where(Certificate.member_name.like(value[1]))
|
||||||
|
|
||||||
result = query.run(as_dict=True)
|
result = query.run(as_dict=True)
|
||||||
return result[0]["total"] if result else 0
|
return len(result) or 0
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
@@ -544,7 +549,7 @@ def get_sidebar_settings():
|
|||||||
items = [
|
items = [
|
||||||
"courses",
|
"courses",
|
||||||
"batches",
|
"batches",
|
||||||
"certified_participants",
|
"certified_members",
|
||||||
"jobs",
|
"jobs",
|
||||||
"statistics",
|
"statistics",
|
||||||
"notifications",
|
"notifications",
|
||||||
@@ -691,13 +696,13 @@ def get_categories(doctype, filters):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_members(start=0, search=""):
|
def get_members(start=0, search=""):
|
||||||
"""Get members for the given search term and start index.
|
"""Get members for the given search term and start index.
|
||||||
Args: start (int): Start index for the query.
|
Args: start (int): Start index for the query.
|
||||||
<<<<<<< HEAD
|
<<<<<<< HEAD
|
||||||
search (str): Search term to filter the results.
|
search (str): Search term to filter the results.
|
||||||
=======
|
=======
|
||||||
search (str): Search term to filter the results.
|
search (str): Search term to filter the results.
|
||||||
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
|
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
|
||||||
Returns: List of members.
|
Returns: List of members.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
||||||
@@ -838,6 +843,14 @@ def delete_documents(doctype, documents):
|
|||||||
frappe.delete_doc(doctype, doc)
|
frappe.delete_doc(doctype, doc)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist(allow_guest=True)
|
||||||
|
def get_count(doctype, filters):
|
||||||
|
return frappe.db.count(
|
||||||
|
doctype,
|
||||||
|
filters=filters,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_payment_gateway_details(payment_gateway):
|
def get_payment_gateway_details(payment_gateway):
|
||||||
fields = []
|
fields = []
|
||||||
@@ -1416,3 +1429,67 @@ def capture_user_persona(responses):
|
|||||||
if response.get("message").get("name"):
|
if response.get("message").get("name"):
|
||||||
frappe.db.set_single_value("LMS Settings", "persona_captured", True)
|
frappe.db.set_single_value("LMS Settings", "persona_captured", True)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_meta_info(type, route):
|
||||||
|
if frappe.db.exists("Website Meta Tag", {"parent": f"{type}/{route}"}):
|
||||||
|
meta_tags = frappe.get_all(
|
||||||
|
"Website Meta Tag",
|
||||||
|
{
|
||||||
|
"parent": f"{type}/{route}",
|
||||||
|
},
|
||||||
|
["name", "key", "value"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return meta_tags
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def update_meta_info(type, route, meta_tags):
|
||||||
|
parent_name = f"{type}/{route}"
|
||||||
|
if not isinstance(meta_tags, list):
|
||||||
|
frappe.throw(_("Meta tags should be a list."))
|
||||||
|
|
||||||
|
for tag in meta_tags:
|
||||||
|
existing_tag = frappe.db.exists(
|
||||||
|
"Website Meta Tag",
|
||||||
|
{
|
||||||
|
"parent": parent_name,
|
||||||
|
"parenttype": "Website Route Meta",
|
||||||
|
"parentfield": "meta_tags",
|
||||||
|
"key": tag["key"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if existing_tag:
|
||||||
|
if not tag.get("value"):
|
||||||
|
frappe.db.delete("Website Meta Tag", existing_tag)
|
||||||
|
continue
|
||||||
|
frappe.db.set_value("Website Meta Tag", existing_tag, "value", tag["value"])
|
||||||
|
elif tag.get("value"):
|
||||||
|
tag_properties = {
|
||||||
|
"parent": parent_name,
|
||||||
|
"parenttype": "Website Route Meta",
|
||||||
|
"parentfield": "meta_tags",
|
||||||
|
"key": tag["key"],
|
||||||
|
"value": tag["value"],
|
||||||
|
}
|
||||||
|
|
||||||
|
parent_exists = frappe.db.exists("Website Route Meta", parent_name)
|
||||||
|
if not parent_exists:
|
||||||
|
route_meta = frappe.new_doc("Website Route Meta")
|
||||||
|
route_meta.update(
|
||||||
|
{
|
||||||
|
"__newname": parent_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
route_meta.append("meta_tags", tag_properties)
|
||||||
|
route_meta.insert()
|
||||||
|
else:
|
||||||
|
new_tag = frappe.new_doc("Website Meta Tag")
|
||||||
|
new_tag.update(tag_properties)
|
||||||
|
print(new_tag)
|
||||||
|
new_tag.insert()
|
||||||
|
print(new_tag.as_dict())
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
"read_only": 1
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
@@ -111,7 +112,7 @@
|
|||||||
"link_fieldname": "chapter"
|
"link_fieldname": "chapter"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-02-03 15:23:17.125617",
|
"modified": "2025-05-29 12:38:26.266673",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "Course Chapter",
|
"name": "Course Chapter",
|
||||||
@@ -151,8 +152,21 @@
|
|||||||
"role": "Course Creator",
|
"role": "Course Creator",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Moderator",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"search_fields": "title",
|
"search_fields": "title",
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
@@ -160,4 +174,4 @@
|
|||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "title",
|
"title_field": "title",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,8 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-03-26 14:02:46.588721",
|
"modified": "2025-06-05 11:04:32.475711",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "Course Evaluator",
|
"name": "Course Evaluator",
|
||||||
"naming_rule": "By fieldname",
|
"naming_rule": "By fieldname",
|
||||||
@@ -133,5 +133,6 @@
|
|||||||
"row_format": "Dynamic",
|
"row_format": "Dynamic",
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": [],
|
||||||
|
"title_field": "full_name"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
import json
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils.telemetry import capture
|
from frappe.utils.telemetry import capture
|
||||||
from lms.lms.utils import get_course_progress
|
from lms.lms.utils import get_course_progress
|
||||||
from ...md import find_macros
|
from ...md import find_macros
|
||||||
import json
|
from frappe.realtime import get_website_room
|
||||||
|
|
||||||
|
|
||||||
class CourseLesson(Document):
|
class CourseLesson(Document):
|
||||||
@@ -76,6 +77,13 @@ def save_progress(lesson, course):
|
|||||||
enrollment.save()
|
enrollment.save()
|
||||||
enrollment.run_method("on_change")
|
enrollment.run_method("on_change")
|
||||||
|
|
||||||
|
frappe.publish_realtime(
|
||||||
|
event="update_lesson_progress",
|
||||||
|
room=get_website_room(),
|
||||||
|
message={"course": course, "lesson": lesson, "progress": progress},
|
||||||
|
after_commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
return progress
|
return progress
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +104,11 @@ def get_quiz_progress(lesson):
|
|||||||
for block in content.get("blocks"):
|
for block in content.get("blocks"):
|
||||||
if block.get("type") == "quiz":
|
if block.get("type") == "quiz":
|
||||||
quizzes.append(block.get("data").get("quiz"))
|
quizzes.append(block.get("data").get("quiz"))
|
||||||
|
if block.get("type") == "upload":
|
||||||
|
quizzes_in_video = block.get("data").get("quizzes")
|
||||||
|
if quizzes_in_video and len(quizzes_in_video) > 0:
|
||||||
|
for row in quizzes_in_video:
|
||||||
|
quizzes.append(row.get("quiz"))
|
||||||
|
|
||||||
elif lesson_details.body:
|
elif lesson_details.body:
|
||||||
macros = find_macros(lesson_details.body)
|
macros = find_macros(lesson_details.body)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"description",
|
"description",
|
||||||
"column_break_hlqw",
|
"column_break_hlqw",
|
||||||
"instructors",
|
"instructors",
|
||||||
|
"zoom_account",
|
||||||
"section_break_rgfj",
|
"section_break_rgfj",
|
||||||
"medium",
|
"medium",
|
||||||
"category",
|
"category",
|
||||||
@@ -354,8 +355,15 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "section_break_cssv",
|
"fieldname": "section_break_cssv",
|
||||||
"fieldtype": "Section Break"
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "zoom_account",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Zoom Account",
|
||||||
|
"options": "LMS Zoom Settings"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
@@ -371,8 +379,8 @@
|
|||||||
"link_fieldname": "batch_name"
|
"link_fieldname": "batch_name"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-02-18 15:43:18.512504",
|
"modified": "2025-05-26 15:30:55.083507",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Batch",
|
"name": "LMS Batch",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
@@ -412,12 +420,22 @@
|
|||||||
"role": "Batch Evaluator",
|
"role": "Batch Evaluator",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "LMS Student",
|
||||||
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "title",
|
"title_field": "title",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class LMSBatch(Document):
|
|||||||
frappe.throw(_("Seat count cannot be negative."))
|
frappe.throw(_("Seat count cannot be negative."))
|
||||||
|
|
||||||
students = frappe.db.count("LMS Batch Enrollment", {"batch": self.name})
|
students = frappe.db.count("LMS Batch Enrollment", {"batch": self.name})
|
||||||
if cint(self.seat_count) < students:
|
if cint(self.seat_count) and cint(self.seat_count) < students:
|
||||||
frappe.throw(_("There are no seats available in this batch."))
|
frappe.throw(_("There are no seats available in this batch."))
|
||||||
|
|
||||||
def validate_timetable(self):
|
def validate_timetable(self):
|
||||||
@@ -146,7 +146,15 @@ class LMSBatch(Document):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def create_live_class(
|
def create_live_class(
|
||||||
batch_name, title, duration, date, time, timezone, auto_recording, description=None
|
batch_name,
|
||||||
|
zoom_account,
|
||||||
|
title,
|
||||||
|
duration,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
timezone,
|
||||||
|
auto_recording,
|
||||||
|
description=None,
|
||||||
):
|
):
|
||||||
frappe.only_for("Moderator")
|
frappe.only_for("Moderator")
|
||||||
payload = {
|
payload = {
|
||||||
@@ -161,7 +169,7 @@ def create_live_class(
|
|||||||
"timezone": timezone,
|
"timezone": timezone,
|
||||||
}
|
}
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": "Bearer " + authenticate(),
|
"Authorization": "Bearer " + authenticate(zoom_account),
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
}
|
}
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
@@ -175,6 +183,8 @@ def create_live_class(
|
|||||||
"doctype": "LMS Live Class",
|
"doctype": "LMS Live Class",
|
||||||
"start_url": data.get("start_url"),
|
"start_url": data.get("start_url"),
|
||||||
"join_url": data.get("join_url"),
|
"join_url": data.get("join_url"),
|
||||||
|
"meeting_id": data.get("id"),
|
||||||
|
"uuid": data.get("uuid"),
|
||||||
"title": title,
|
"title": title,
|
||||||
"host": frappe.session.user,
|
"host": frappe.session.user,
|
||||||
"date": date,
|
"date": date,
|
||||||
@@ -183,6 +193,7 @@ def create_live_class(
|
|||||||
"password": data.get("password"),
|
"password": data.get("password"),
|
||||||
"description": description,
|
"description": description,
|
||||||
"auto_recording": auto_recording,
|
"auto_recording": auto_recording,
|
||||||
|
"zoom_account": zoom_account,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
class_details = frappe.get_doc(payload)
|
class_details = frappe.get_doc(payload)
|
||||||
@@ -194,10 +205,10 @@ def create_live_class(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def authenticate():
|
def authenticate(zoom_account):
|
||||||
zoom = frappe.get_single("Zoom Settings")
|
zoom = frappe.get_doc("LMS Zoom Settings", zoom_account)
|
||||||
if not zoom.enable:
|
if not zoom.enabled:
|
||||||
frappe.throw(_("Please enable Zoom Settings to use this feature."))
|
frappe.throw(_("Please enable the zoom account to use this feature."))
|
||||||
|
|
||||||
authenticate_url = f"https://zoom.us/oauth/token?grant_type=account_credentials&account_id={zoom.account_id}"
|
authenticate_url = f"https://zoom.us/oauth/token?grant_type=account_credentials&account_id={zoom.account_id}"
|
||||||
|
|
||||||
|
|||||||
@@ -73,10 +73,11 @@
|
|||||||
"read_only": 1
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-01-13 19:02:58.259908",
|
"modified": "2025-05-21 15:58:51.667270",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Batch Feedback",
|
"name": "LMS Batch Feedback",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
@@ -106,7 +107,9 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": [],
|
||||||
}
|
"title_field": "member"
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ class LMSCertificateRequest(Document):
|
|||||||
req.date == getdate(self.date)
|
req.date == getdate(self.date)
|
||||||
or getdate() < getdate(req.date)
|
or getdate() < getdate(req.date)
|
||||||
or (
|
or (
|
||||||
getdate() == getdate(req.date)
|
getdate() == getdate(req.date) and get_time(nowtime()) < get_time(req.start_time)
|
||||||
and getdate(self.start_time) < getdate(req.start_time)
|
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
course_title = frappe.db.get_value("LMS Course", req.course, "title")
|
course_title = frappe.db.get_value("LMS Course", req.course, "title")
|
||||||
|
|||||||
@@ -290,7 +290,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2025-03-13 16:01:19.105212",
|
"modified": "2025-05-29 12:38:01.002898",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Course",
|
"name": "LMS Course",
|
||||||
@@ -319,12 +319,25 @@
|
|||||||
"role": "Course Creator",
|
"role": "Course Creator",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Moderator",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "title",
|
"title_field": "title",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ class LMSEnrollment(Document):
|
|||||||
def create_membership(
|
def create_membership(
|
||||||
course, batch=None, member=None, member_type="Student", role="Member"
|
course, batch=None, member=None, member_type="Student", role="Member"
|
||||||
):
|
):
|
||||||
frappe.get_doc(
|
if frappe.db.get_value("LMS Course", course, "disable_self_learning"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
enrollment = frappe.new_doc("LMS Enrollment")
|
||||||
|
enrollment.update(
|
||||||
{
|
{
|
||||||
"doctype": "LMS Enrollment",
|
"doctype": "LMS Enrollment",
|
||||||
"batch_old": batch,
|
"batch_old": batch,
|
||||||
@@ -93,8 +97,9 @@ def create_membership(
|
|||||||
"member_type": member_type,
|
"member_type": member_type,
|
||||||
"member": member or frappe.session.user,
|
"member": member or frappe.session.user,
|
||||||
}
|
}
|
||||||
).save(ignore_permissions=True)
|
)
|
||||||
return "OK"
|
enrollment.insert()
|
||||||
|
return enrollment
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|||||||
@@ -9,21 +9,27 @@
|
|||||||
"field_order": [
|
"field_order": [
|
||||||
"title",
|
"title",
|
||||||
"host",
|
"host",
|
||||||
|
"zoom_account",
|
||||||
"batch_name",
|
"batch_name",
|
||||||
"event",
|
|
||||||
"column_break_astv",
|
"column_break_astv",
|
||||||
"description",
|
|
||||||
"section_break_glxh",
|
|
||||||
"date",
|
"date",
|
||||||
"duration",
|
|
||||||
"column_break_spvt",
|
|
||||||
"time",
|
"time",
|
||||||
|
"duration",
|
||||||
"timezone",
|
"timezone",
|
||||||
"section_break_yrpq",
|
"section_break_glxh",
|
||||||
|
"description",
|
||||||
|
"column_break_spvt",
|
||||||
|
"event",
|
||||||
|
"auto_recording",
|
||||||
|
"section_break_fhet",
|
||||||
|
"meeting_id",
|
||||||
|
"uuid",
|
||||||
|
"column_break_aony",
|
||||||
|
"attendees",
|
||||||
"password",
|
"password",
|
||||||
|
"section_break_yrpq",
|
||||||
"start_url",
|
"start_url",
|
||||||
"column_break_yokr",
|
"column_break_yokr",
|
||||||
"auto_recording",
|
|
||||||
"join_url"
|
"join_url"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -73,8 +79,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "section_break_glxh",
|
"fieldname": "section_break_glxh",
|
||||||
"fieldtype": "Section Break",
|
"fieldtype": "Section Break"
|
||||||
"label": "Date and Time"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_spvt",
|
"fieldname": "column_break_spvt",
|
||||||
@@ -130,13 +135,50 @@
|
|||||||
"label": "Event",
|
"label": "Event",
|
||||||
"options": "Event",
|
"options": "Event",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "zoom_account",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Zoom Account",
|
||||||
|
"options": "LMS Zoom Settings",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "meeting_id",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Meeting ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "attendees",
|
||||||
|
"fieldtype": "Int",
|
||||||
|
"label": "Attendees",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_fhet",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "uuid",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "UUID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_aony",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"in_create": 1,
|
"in_create": 1,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [
|
||||||
"modified": "2024-11-11 18:59:26.396111",
|
{
|
||||||
"modified_by": "Administrator",
|
"link_doctype": "LMS Live Class Participant",
|
||||||
|
"link_fieldname": "live_class"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"modified": "2025-05-27 14:44:35.679712",
|
||||||
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Live Class",
|
"name": "LMS Live Class",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
@@ -175,10 +217,11 @@
|
|||||||
"share": 1
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "title",
|
"title_field": "title",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from frappe.utils import cint, get_datetime, format_date, nowdate, format_time
|
from frappe.utils import cint, get_datetime, format_date, nowdate, format_time
|
||||||
|
from lms.lms.doctype.lms_batch.lms_batch import authenticate
|
||||||
|
|
||||||
|
|
||||||
class LMSLiveClass(Document):
|
class LMSLiveClass(Document):
|
||||||
@@ -102,3 +105,56 @@ def send_mail(live_class, student):
|
|||||||
args=args,
|
args=args,
|
||||||
header=[_(f"Class Reminder: {live_class.title}"), "orange"],
|
header=[_(f"Class Reminder: {live_class.title}"), "orange"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_attendance():
|
||||||
|
past_live_classes = frappe.get_all(
|
||||||
|
"LMS Live Class",
|
||||||
|
{
|
||||||
|
"uuid": ["is", "set"],
|
||||||
|
"attendees": ["is", "not set"],
|
||||||
|
},
|
||||||
|
["name", "uuid", "zoom_account"],
|
||||||
|
)
|
||||||
|
|
||||||
|
for live_class in past_live_classes:
|
||||||
|
attendance_data = get_attendance(live_class)
|
||||||
|
create_attendance(live_class, attendance_data)
|
||||||
|
update_attendees_count(live_class, attendance_data)
|
||||||
|
|
||||||
|
|
||||||
|
def get_attendance(live_class):
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + authenticate(live_class.zoom_account),
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded_uuid = requests.utils.quote(live_class.uuid, safe="")
|
||||||
|
response = requests.get(
|
||||||
|
f"https://api.zoom.us/v2/past_meetings/{encoded_uuid}/participants", headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
frappe.throw(
|
||||||
|
_("Failed to fetch attendance data from Zoom for class {0}: {1}").format(
|
||||||
|
live_class, response.text
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return data.get("participants", [])
|
||||||
|
|
||||||
|
|
||||||
|
def create_attendance(live_class, data):
|
||||||
|
for participant in data:
|
||||||
|
doc = frappe.new_doc("LMS Live Class Participant")
|
||||||
|
doc.live_class = live_class.name
|
||||||
|
doc.member = participant.get("user_email")
|
||||||
|
doc.joined_at = participant.get("join_time")
|
||||||
|
doc.left_at = participant.get("leave_time")
|
||||||
|
doc.duration = participant.get("duration")
|
||||||
|
doc.insert()
|
||||||
|
|
||||||
|
|
||||||
|
def update_attendees_count(live_class, data):
|
||||||
|
frappe.db.set_value("LMS Live Class", live_class.name, "attendees", len(data))
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("LMS Live Class Participant", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"creation": "2025-05-27 12:09:57.712221",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"live_class",
|
||||||
|
"joined_at",
|
||||||
|
"column_break_dwbm",
|
||||||
|
"duration",
|
||||||
|
"left_at",
|
||||||
|
"section_break_xczy",
|
||||||
|
"member",
|
||||||
|
"member_name",
|
||||||
|
"column_break_bpjn",
|
||||||
|
"member_image",
|
||||||
|
"member_username"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "live_class",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
|
"label": "Live Class",
|
||||||
|
"options": "LMS Live Class",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "member",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_standard_filter": 1,
|
||||||
|
"label": "Member",
|
||||||
|
"options": "User",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "member.full_name",
|
||||||
|
"fieldname": "member_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Member Name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_dwbm",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "duration",
|
||||||
|
"fieldtype": "Int",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Duration",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "joined_at",
|
||||||
|
"fieldtype": "Datetime",
|
||||||
|
"label": "Joined At",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "left_at",
|
||||||
|
"fieldtype": "Datetime",
|
||||||
|
"label": "Left At",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_xczy",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_bpjn",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "member.user_image",
|
||||||
|
"fieldname": "member_image",
|
||||||
|
"fieldtype": "Attach Image",
|
||||||
|
"label": "Member Image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "member.username",
|
||||||
|
"fieldname": "member_username",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Member Username"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-05-27 22:32:24.196643",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "LMS",
|
||||||
|
"name": "LMS Live Class Participant",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": [],
|
||||||
|
"title_field": "member_name"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2025, Frappe and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class LMSLiveClassParticipant(Document):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Copyright (c) 2025, Frappe and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
class UnitTestLMSLiveClassParticipant(UnitTestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for LMSLiveClassParticipant.
|
||||||
|
Use this class for testing individual functions and methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestLMSLiveClassParticipant(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for LMSLiveClassParticipant.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"courses",
|
"courses",
|
||||||
"batches",
|
"batches",
|
||||||
"certified_participants",
|
"certified_participants",
|
||||||
|
"certified_members",
|
||||||
"column_break_exdz",
|
"column_break_exdz",
|
||||||
"jobs",
|
"jobs",
|
||||||
"statistics",
|
"statistics",
|
||||||
@@ -277,6 +278,7 @@
|
|||||||
"default": "1",
|
"default": "1",
|
||||||
"fieldname": "certified_participants",
|
"fieldname": "certified_participants",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
|
"hidden": 1,
|
||||||
"label": "Certified Participants"
|
"label": "Certified Participants"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -397,13 +399,19 @@
|
|||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Persona Captured",
|
"label": "Persona Captured",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "certified_members",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Certified Members"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-05-14 12:43:22.749850",
|
"modified": "2025-05-30 19:02:51.381668",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Settings",
|
"name": "LMS Settings",
|
||||||
|
|||||||
0
lms/lms/doctype/lms_zoom_settings/__init__.py
Normal file
0
lms/lms/doctype/lms_zoom_settings/__init__.py
Normal file
8
lms/lms/doctype/lms_zoom_settings/lms_zoom_settings.js
Normal file
8
lms/lms/doctype/lms_zoom_settings/lms_zoom_settings.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("LMS Zoom Settings", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
128
lms/lms/doctype/lms_zoom_settings/lms_zoom_settings.json
Normal file
128
lms/lms/doctype/lms_zoom_settings/lms_zoom_settings.json
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"autoname": "field:account_name",
|
||||||
|
"creation": "2025-05-26 13:04:18.285735",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"enabled",
|
||||||
|
"section_break_xfow",
|
||||||
|
"account_name",
|
||||||
|
"member",
|
||||||
|
"member_name",
|
||||||
|
"column_break_fxxg",
|
||||||
|
"account_id",
|
||||||
|
"client_id",
|
||||||
|
"client_secret"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "enabled",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Enabled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "account_id",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Account ID",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "client_id",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Client ID",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "client_secret",
|
||||||
|
"fieldtype": "Password",
|
||||||
|
"label": "Client Secret",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "member",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
|
"label": "Member",
|
||||||
|
"options": "User",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "member.full_name",
|
||||||
|
"fieldname": "member_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Member Name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_xfow",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "account_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Account Name",
|
||||||
|
"reqd": 1,
|
||||||
|
"unique": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_fxxg",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-05-26 18:09:09.392368",
|
||||||
|
"modified_by": "sayali@frappe.io",
|
||||||
|
"module": "LMS",
|
||||||
|
"name": "LMS Zoom Settings",
|
||||||
|
"naming_rule": "By fieldname",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Moderator",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"if_owner": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Batch Evaluator",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
9
lms/lms/doctype/lms_zoom_settings/lms_zoom_settings.py
Normal file
9
lms/lms/doctype/lms_zoom_settings/lms_zoom_settings.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2025, Frappe and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class LMSZoomSettings(Document):
|
||||||
|
pass
|
||||||
30
lms/lms/doctype/lms_zoom_settings/test_lms_zoom_settings.py
Normal file
30
lms/lms/doctype/lms_zoom_settings/test_lms_zoom_settings.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Copyright (c) 2025, Frappe and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
class UnitTestLMSZoomSettings(UnitTestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for LMSZoomSettings.
|
||||||
|
Use this class for testing individual functions and methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestLMSZoomSettings(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for LMSZoomSettings.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
import frappe
|
import frappe
|
||||||
|
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def is_enabled():
|
def get_posthog_settings():
|
||||||
return bool(
|
|
||||||
frappe.get_system_settings("enable_telemetry")
|
|
||||||
and frappe.conf.get("posthog_host")
|
|
||||||
and frappe.conf.get("posthog_project_id")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_credentials():
|
|
||||||
return {
|
return {
|
||||||
"project_id": frappe.conf.get("posthog_project_id"),
|
"posthog_project_id": frappe.conf.get(POSTHOG_PROJECT_FIELD),
|
||||||
"telemetry_host": frappe.conf.get("posthog_host"),
|
"posthog_host": frappe.conf.get(POSTHOG_HOST_FIELD),
|
||||||
|
"enable_telemetry": frappe.get_system_settings("enable_telemetry"),
|
||||||
|
"telemetry_site_age": frappe.utils.telemetry.site_age(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -961,15 +961,6 @@ def apply_gst(amount, country=None):
|
|||||||
return amount, gst_applied
|
return amount, gst_applied
|
||||||
|
|
||||||
|
|
||||||
def create_membership(course, payment):
|
|
||||||
membership = frappe.new_doc("LMS Enrollment")
|
|
||||||
membership.update(
|
|
||||||
{"member": frappe.session.user, "course": course, "payment": payment.name}
|
|
||||||
)
|
|
||||||
membership.save(ignore_permissions=True)
|
|
||||||
return f"/lms/courses/{course}/learn/1-1"
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_exchange_rate(source, target="USD"):
|
def get_current_exchange_rate(source, target="USD"):
|
||||||
url = f"https://api.frankfurter.app/latest?from={source}&to={target}"
|
url = f"https://api.frankfurter.app/latest?from={source}&to={target}"
|
||||||
|
|
||||||
@@ -1391,6 +1382,7 @@ def get_batch_details(batch):
|
|||||||
"certification",
|
"certification",
|
||||||
"timezone",
|
"timezone",
|
||||||
"category",
|
"category",
|
||||||
|
"zoom_account",
|
||||||
],
|
],
|
||||||
as_dict=True,
|
as_dict=True,
|
||||||
)
|
)
|
||||||
|
|||||||
1291
lms/locale/ar.po
1291
lms/locale/ar.po
File diff suppressed because it is too large
Load Diff
1301
lms/locale/bs.po
1301
lms/locale/bs.po
File diff suppressed because it is too large
Load Diff
1293
lms/locale/de.po
1293
lms/locale/de.po
File diff suppressed because it is too large
Load Diff
1301
lms/locale/eo.po
1301
lms/locale/eo.po
File diff suppressed because it is too large
Load Diff
1295
lms/locale/es.po
1295
lms/locale/es.po
File diff suppressed because it is too large
Load Diff
1299
lms/locale/fa.po
1299
lms/locale/fa.po
File diff suppressed because it is too large
Load Diff
1301
lms/locale/fr.po
1301
lms/locale/fr.po
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user